feat: Add registration form steps for officer and personal information, selfie verification, and unit details
- Implemented OfficerInfoStep widget for officer details input. - Implemented PersonalInfoStep widget for personal details input. - Implemented SelfieVerificationStep widget for selfie upload and verification. - Implemented UnitInfoStep widget for unit details input. - Created step form screen to manage the registration process with navigation between steps.
This commit is contained in:
parent
b003d8a158
commit
ce7d448b2f
|
@ -39,3 +39,6 @@ MAPBOX_ACCESS_TOKEN=pk.eyJ1IjoidmVyZ2lsZ29vZDEiLCJhIjoiY205b254eGltMGJ5dzJqb2F4c
|
||||||
MAPBOX_TILESET_ID=vergilgood1.cm9x176pl09k11ope7hzkij0r-06afz
|
MAPBOX_TILESET_ID=vergilgood1.cm9x176pl09k11ope7hzkij0r-06afz
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Azure AI API
|
||||||
|
AZURE_RESOURCE_NAME="sigap"
|
||||||
|
AZURE_SUBSCRIPTION_KEY="ANeYAEr78MF7HzCEDg53DEHfKZJg19raPeJCubNEZP2tXGD6xREgJQQJ99BEAC3pKaRXJ3w3AAAFACOGAwA9"
|
|
@ -1,8 +1,9 @@
|
||||||
import 'package:get/get.dart';
|
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/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/signin/signin_screen.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/signup/signup_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/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/role-selection/role_selection_screen.dart';
|
||||||
import 'package:sigap/src/features/onboarding/presentasion/pages/welcome/welcome_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(),
|
||||||
|
)
|
||||||
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,16 +3,16 @@ import 'dart:io';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/api_urls.dart';
|
||||||
import 'package:sigap/src/utils/dio.client/dio_client.dart';
|
import 'package:sigap/src/utils/dio.client/dio_client.dart';
|
||||||
|
|
||||||
class AzureOCRService {
|
class AzureOCRService {
|
||||||
// Replace with your Azure Computer Vision API endpoint and key
|
// Azure OCR API endpoint and subscription key
|
||||||
static const String endpoint =
|
final String endpoint = Endpoints.azureEndpoint;
|
||||||
'https://your-azure-endpoint.cognitiveservices.azure.com/';
|
final String subscriptionKey = Endpoints.azureSubscriptionKey;
|
||||||
static const String subscriptionKey = 'your-azure-subscription-key';
|
final String ocrApiPath = Endpoints.ocrApiPath;
|
||||||
static const String ocrApiPath = 'vision/v3.2/ocr';
|
final String faceApiPath = Endpoints.faceApiPath;
|
||||||
static const String faceApiPath = 'face/v1.0/detect';
|
final String faceVerifyPath = Endpoints.faceVerifyPath;
|
||||||
static const String faceVerifyPath = 'face/v1.0/verify';
|
|
||||||
|
|
||||||
// Process an ID card image and extract relevant information
|
// Process an ID card image and extract relevant information
|
||||||
Future<Map<String, String>> processIdCard(
|
Future<Map<String, String>> processIdCard(
|
||||||
|
|
|
@ -0,0 +1,530 @@
|
||||||
|
// List of Indonesian cities and regencies
|
||||||
|
// This is a partial list - in a real app, you would have a complete list
|
||||||
|
final List<String> indonesianCities = [
|
||||||
|
'Jakarta',
|
||||||
|
'Surabaya',
|
||||||
|
'Bandung',
|
||||||
|
'Medan',
|
||||||
|
'Semarang',
|
||||||
|
'Makassar',
|
||||||
|
'Palembang',
|
||||||
|
'Tangerang',
|
||||||
|
'Depok',
|
||||||
|
'Bekasi',
|
||||||
|
'Bogor',
|
||||||
|
'Malang',
|
||||||
|
'Yogyakarta',
|
||||||
|
'Denpasar',
|
||||||
|
'Balikpapan',
|
||||||
|
'Banjarmasin',
|
||||||
|
'Manado',
|
||||||
|
'Padang',
|
||||||
|
'Pekanbaru',
|
||||||
|
'Pontianak',
|
||||||
|
'Bandar Lampung',
|
||||||
|
'Cirebon',
|
||||||
|
'Tasikmalaya',
|
||||||
|
'Serang',
|
||||||
|
'Jambi',
|
||||||
|
'Bengkulu',
|
||||||
|
'Ambon',
|
||||||
|
'Kupang',
|
||||||
|
'Mataram',
|
||||||
|
'Palu',
|
||||||
|
'Samarinda',
|
||||||
|
'Kendari',
|
||||||
|
'Jayapura',
|
||||||
|
'Sorong',
|
||||||
|
'Gorontalo',
|
||||||
|
'Ternate',
|
||||||
|
'Tanjung Pinang',
|
||||||
|
'Pangkal Pinang',
|
||||||
|
'Mamuju',
|
||||||
|
'Banda Aceh',
|
||||||
|
'Tegal',
|
||||||
|
'Pekalongan',
|
||||||
|
'Magelang',
|
||||||
|
'Sukabumi',
|
||||||
|
'Cilegon',
|
||||||
|
'Kediri',
|
||||||
|
'Pematang Siantar',
|
||||||
|
'Binjai',
|
||||||
|
'Tanjung Balai',
|
||||||
|
'Bitung',
|
||||||
|
'Pare-Pare',
|
||||||
|
'Tarakan',
|
||||||
|
'Bontang',
|
||||||
|
'Bau-Bau',
|
||||||
|
'Palopo',
|
||||||
|
'Sawahlunto',
|
||||||
|
'Padang Panjang',
|
||||||
|
'Bukittinggi',
|
||||||
|
'Payakumbuh',
|
||||||
|
'Pariaman',
|
||||||
|
'Solok',
|
||||||
|
'Lubuklinggau',
|
||||||
|
'Prabumulih',
|
||||||
|
'Palangkaraya',
|
||||||
|
'Singkawang',
|
||||||
|
'Bima',
|
||||||
|
'Probolinggo',
|
||||||
|
'Pasuruan',
|
||||||
|
'Mojokerto',
|
||||||
|
'Madiun',
|
||||||
|
'Blitar',
|
||||||
|
'Batu',
|
||||||
|
'Cianjur',
|
||||||
|
'Garut',
|
||||||
|
'Purwakarta',
|
||||||
|
'Ciamis',
|
||||||
|
'Karawang',
|
||||||
|
'Subang',
|
||||||
|
'Indramayu',
|
||||||
|
'Majalengka',
|
||||||
|
'Kuningan',
|
||||||
|
'Banjar',
|
||||||
|
'Banjarbaru',
|
||||||
|
'Kotabaru',
|
||||||
|
'Sampit',
|
||||||
|
'Pangkalan Bun',
|
||||||
|
'Tanjung Selor',
|
||||||
|
'Tanjung Redeb',
|
||||||
|
'Nunukan',
|
||||||
|
'Malinau',
|
||||||
|
'Tanjung Pandan',
|
||||||
|
'Manggar',
|
||||||
|
'Sungai Liat',
|
||||||
|
'Tanjung Pati',
|
||||||
|
'Sungai Penuh',
|
||||||
|
'Muara Bungo',
|
||||||
|
'Muara Bulian',
|
||||||
|
'Muara Tebo',
|
||||||
|
'Bangko',
|
||||||
|
'Kuala Tungkal',
|
||||||
|
'Muara Sabak',
|
||||||
|
'Muara Enim',
|
||||||
|
'Lahat',
|
||||||
|
'Baturaja',
|
||||||
|
'Martapura',
|
||||||
|
'Kayu Agung',
|
||||||
|
'Sekayu',
|
||||||
|
'Pangkalpinang',
|
||||||
|
'Metro',
|
||||||
|
'Pringsewu',
|
||||||
|
'Kota Agung',
|
||||||
|
'Liwa',
|
||||||
|
'Menggala',
|
||||||
|
'Kotabumi',
|
||||||
|
'Krui',
|
||||||
|
'Sukadana',
|
||||||
|
'Curup',
|
||||||
|
'Manna',
|
||||||
|
'Argamakmur',
|
||||||
|
'Mukomuko',
|
||||||
|
'Tais',
|
||||||
|
'Bintuhan',
|
||||||
|
'Kaur',
|
||||||
|
'Kepahiang',
|
||||||
|
'Lebong',
|
||||||
|
'Muara Aman',
|
||||||
|
'Seluma',
|
||||||
|
'Tanjung Karang',
|
||||||
|
'Teluk Betung',
|
||||||
|
'Kalianda',
|
||||||
|
'Gunung Sugih',
|
||||||
|
'Blambangan Umpu',
|
||||||
|
'Kotabumi',
|
||||||
|
'Gedong Tataan',
|
||||||
|
'Menggala',
|
||||||
|
'Kota Agung',
|
||||||
|
'Sukadana',
|
||||||
|
'Panarukan',
|
||||||
|
'Situbondo',
|
||||||
|
'Bondowoso',
|
||||||
|
'Jember',
|
||||||
|
'Banyuwangi',
|
||||||
|
'Lumajang',
|
||||||
|
'Kraksaan',
|
||||||
|
'Bangkalan',
|
||||||
|
'Sampang',
|
||||||
|
'Pamekasan',
|
||||||
|
'Sumenep',
|
||||||
|
'Ngawi',
|
||||||
|
'Ponorogo',
|
||||||
|
'Pacitan',
|
||||||
|
'Magetan',
|
||||||
|
'Nganjuk',
|
||||||
|
'Jombang',
|
||||||
|
'Tuban',
|
||||||
|
'Bojonegoro',
|
||||||
|
'Lamongan',
|
||||||
|
'Gresik',
|
||||||
|
'Sidoarjo',
|
||||||
|
'Mojokerto',
|
||||||
|
'Pasuruan',
|
||||||
|
'Probolinggo',
|
||||||
|
'Lumajang',
|
||||||
|
'Jember',
|
||||||
|
'Banyuwangi',
|
||||||
|
'Situbondo',
|
||||||
|
'Bondowoso',
|
||||||
|
'Trenggalek',
|
||||||
|
'Tulungagung',
|
||||||
|
'Blitar',
|
||||||
|
'Kediri',
|
||||||
|
'Malang',
|
||||||
|
'Purworejo',
|
||||||
|
'Kebumen',
|
||||||
|
'Magelang',
|
||||||
|
'Wonosobo',
|
||||||
|
'Temanggung',
|
||||||
|
'Kendal',
|
||||||
|
'Batang',
|
||||||
|
'Pekalongan',
|
||||||
|
'Pemalang',
|
||||||
|
'Tegal',
|
||||||
|
'Brebes',
|
||||||
|
'Banyumas',
|
||||||
|
'Cilacap',
|
||||||
|
'Purbalingga',
|
||||||
|
'Banjarnegara',
|
||||||
|
'Sragen',
|
||||||
|
'Karanganyar',
|
||||||
|
'Wonogiri',
|
||||||
|
'Sukoharjo',
|
||||||
|
'Klaten',
|
||||||
|
'Boyolali',
|
||||||
|
'Grobogan',
|
||||||
|
'Blora',
|
||||||
|
'Rembang',
|
||||||
|
'Pati',
|
||||||
|
'Kudus',
|
||||||
|
'Jepara',
|
||||||
|
'Demak',
|
||||||
|
'Semarang',
|
||||||
|
'Salatiga',
|
||||||
|
'Surakarta',
|
||||||
|
'Bantul',
|
||||||
|
'Sleman',
|
||||||
|
'Kulon Progo',
|
||||||
|
'Gunung Kidul',
|
||||||
|
'Badung',
|
||||||
|
'Bangli',
|
||||||
|
'Buleleng',
|
||||||
|
'Gianyar',
|
||||||
|
'Jembrana',
|
||||||
|
'Karangasem',
|
||||||
|
'Klungkung',
|
||||||
|
'Tabanan',
|
||||||
|
'Mataram',
|
||||||
|
'Bima',
|
||||||
|
'Dompu',
|
||||||
|
'Sumbawa',
|
||||||
|
'Lombok Barat',
|
||||||
|
'Lombok Tengah',
|
||||||
|
'Lombok Timur',
|
||||||
|
'Lombok Utara',
|
||||||
|
'Kupang',
|
||||||
|
'Ende',
|
||||||
|
'Maumere',
|
||||||
|
'Labuan Bajo',
|
||||||
|
'Ruteng',
|
||||||
|
'Waingapu',
|
||||||
|
'Waikabubak',
|
||||||
|
'Atambua',
|
||||||
|
'Kefamenanu',
|
||||||
|
'Soe',
|
||||||
|
'Bajawa',
|
||||||
|
'Larantuka',
|
||||||
|
'Lewoleba',
|
||||||
|
'Kalabahi',
|
||||||
|
'Sabu',
|
||||||
|
'Rote',
|
||||||
|
'Alor',
|
||||||
|
'Lembata',
|
||||||
|
'Flores Timur',
|
||||||
|
'Sikka',
|
||||||
|
'Ende',
|
||||||
|
'Ngada',
|
||||||
|
'Manggarai',
|
||||||
|
'Manggarai Barat',
|
||||||
|
'Manggarai Timur',
|
||||||
|
'Sumba Barat',
|
||||||
|
'Sumba Timur',
|
||||||
|
'Sumba Tengah',
|
||||||
|
'Sumba Barat Daya',
|
||||||
|
'Belu',
|
||||||
|
'Malaka',
|
||||||
|
'Timor Tengah Utara',
|
||||||
|
'Timor Tengah Selatan',
|
||||||
|
'Rote Ndao',
|
||||||
|
'Sabu Raijua',
|
||||||
|
'Pontianak',
|
||||||
|
'Singkawang',
|
||||||
|
'Sambas',
|
||||||
|
'Bengkayang',
|
||||||
|
'Landak',
|
||||||
|
'Mempawah',
|
||||||
|
'Sanggau',
|
||||||
|
'Ketapang',
|
||||||
|
'Sintang',
|
||||||
|
'Kapuas Hulu',
|
||||||
|
'Sekadau',
|
||||||
|
'Melawi',
|
||||||
|
'Kayong Utara',
|
||||||
|
'Kubu Raya',
|
||||||
|
'Palangkaraya',
|
||||||
|
'Sampit',
|
||||||
|
'Pangkalan Bun',
|
||||||
|
'Kuala Kapuas',
|
||||||
|
'Buntok',
|
||||||
|
'Muara Teweh',
|
||||||
|
'Puruk Cahu',
|
||||||
|
'Kuala Kurun',
|
||||||
|
'Kuala Pembuang',
|
||||||
|
'Kasongan',
|
||||||
|
'Tamiang Layang',
|
||||||
|
'Nanga Bulik',
|
||||||
|
'Sukamara',
|
||||||
|
'Pulang Pisau',
|
||||||
|
'Lamandau',
|
||||||
|
'Seruyan',
|
||||||
|
'Katingan',
|
||||||
|
'Gunung Mas',
|
||||||
|
'Barito Timur',
|
||||||
|
'Barito Utara',
|
||||||
|
'Barito Selatan',
|
||||||
|
'Murung Raya',
|
||||||
|
'Banjarmasin',
|
||||||
|
'Banjarbaru',
|
||||||
|
'Martapura',
|
||||||
|
'Pelaihari',
|
||||||
|
'Kotabaru',
|
||||||
|
'Tanjung',
|
||||||
|
'Barabai',
|
||||||
|
'Amuntai',
|
||||||
|
'Kandangan',
|
||||||
|
'Rantau',
|
||||||
|
'Marabahan',
|
||||||
|
'Paringin',
|
||||||
|
'Balangan',
|
||||||
|
'Tanah Bumbu',
|
||||||
|
'Tanah Laut',
|
||||||
|
'Tapin',
|
||||||
|
'Hulu Sungai Selatan',
|
||||||
|
'Hulu Sungai Tengah',
|
||||||
|
'Hulu Sungai Utara',
|
||||||
|
'Tabalong',
|
||||||
|
'Barito Kuala',
|
||||||
|
'Samarinda',
|
||||||
|
'Balikpapan',
|
||||||
|
'Bontang',
|
||||||
|
'Tenggarong',
|
||||||
|
'Sangatta',
|
||||||
|
'Sendawar',
|
||||||
|
'Tanah Grogot',
|
||||||
|
'Penajam',
|
||||||
|
'Tanjung Redeb',
|
||||||
|
'Tanjung Selor',
|
||||||
|
'Malinau',
|
||||||
|
'Tarakan',
|
||||||
|
'Nunukan',
|
||||||
|
'Kutai Kartanegara',
|
||||||
|
'Kutai Timur',
|
||||||
|
'Kutai Barat',
|
||||||
|
'Paser',
|
||||||
|
'Penajam Paser Utara',
|
||||||
|
'Berau',
|
||||||
|
'Bulungan',
|
||||||
|
'Malinau',
|
||||||
|
'Nunukan',
|
||||||
|
'Tana Tidung',
|
||||||
|
'Makassar',
|
||||||
|
'Pare-Pare',
|
||||||
|
'Palopo',
|
||||||
|
'Maros',
|
||||||
|
'Pangkajene',
|
||||||
|
'Barru',
|
||||||
|
'Watampone',
|
||||||
|
'Watansoppeng',
|
||||||
|
'Sengkang',
|
||||||
|
'Makale',
|
||||||
|
'Rantepao',
|
||||||
|
'Enrekang',
|
||||||
|
'Pinrang',
|
||||||
|
'Sidenreng Rappang',
|
||||||
|
'Sungguminasa',
|
||||||
|
'Takalar',
|
||||||
|
'Jeneponto',
|
||||||
|
'Bantaeng',
|
||||||
|
'Bulukumba',
|
||||||
|
'Sinjai',
|
||||||
|
'Benteng',
|
||||||
|
'Bau-Bau',
|
||||||
|
'Kendari',
|
||||||
|
'Kolaka',
|
||||||
|
'Raha',
|
||||||
|
'Unaaha',
|
||||||
|
'Andoolo',
|
||||||
|
'Rumbia',
|
||||||
|
'Wangi-Wangi',
|
||||||
|
'Baubau',
|
||||||
|
'Bombana',
|
||||||
|
'Buton',
|
||||||
|
'Buton Utara',
|
||||||
|
'Kolaka',
|
||||||
|
'Kolaka Timur',
|
||||||
|
'Kolaka Utara',
|
||||||
|
'Konawe',
|
||||||
|
'Konawe Selatan',
|
||||||
|
'Konawe Utara',
|
||||||
|
'Muna',
|
||||||
|
'Muna Barat',
|
||||||
|
'Wakatobi',
|
||||||
|
'Palu',
|
||||||
|
'Luwuk',
|
||||||
|
'Toli-Toli',
|
||||||
|
'Buol',
|
||||||
|
'Donggala',
|
||||||
|
'Poso',
|
||||||
|
'Ampana',
|
||||||
|
'Bungku',
|
||||||
|
'Kolonodale',
|
||||||
|
'Banggai',
|
||||||
|
'Banggai Kepulauan',
|
||||||
|
'Banggai Laut',
|
||||||
|
'Buol',
|
||||||
|
'Donggala',
|
||||||
|
'Morowali',
|
||||||
|
'Morowali Utara',
|
||||||
|
'Parigi Moutong',
|
||||||
|
'Poso',
|
||||||
|
'Sigi',
|
||||||
|
'Tojo Una-Una',
|
||||||
|
'Toli-Toli',
|
||||||
|
'Manado',
|
||||||
|
'Bitung',
|
||||||
|
'Tomohon',
|
||||||
|
'Kotamobagu',
|
||||||
|
'Tahuna',
|
||||||
|
'Melonguane',
|
||||||
|
'Airmadidi',
|
||||||
|
'Ratahan',
|
||||||
|
'Amurang',
|
||||||
|
'Tondano',
|
||||||
|
'Bolaang Mongondow',
|
||||||
|
'Bolaang Mongondow Selatan',
|
||||||
|
'Bolaang Mongondow Timur',
|
||||||
|
'Bolaang Mongondow Utara',
|
||||||
|
'Kepulauan Sangihe',
|
||||||
|
'Kepulauan Siau Tagulandang Biaro',
|
||||||
|
'Kepulauan Talaud',
|
||||||
|
'Minahasa',
|
||||||
|
'Minahasa Selatan',
|
||||||
|
'Minahasa Tenggara',
|
||||||
|
'Minahasa Utara',
|
||||||
|
'Gorontalo',
|
||||||
|
'Limboto',
|
||||||
|
'Marisa',
|
||||||
|
'Tilamuta',
|
||||||
|
'Suwawa',
|
||||||
|
'Kwandang',
|
||||||
|
'Boalemo',
|
||||||
|
'Bone Bolango',
|
||||||
|
'Gorontalo',
|
||||||
|
'Gorontalo Utara',
|
||||||
|
'Pohuwato',
|
||||||
|
'Ternate',
|
||||||
|
'Tidore',
|
||||||
|
'Jailolo',
|
||||||
|
'Weda',
|
||||||
|
'Labuha',
|
||||||
|
'Tobelo',
|
||||||
|
'Maba',
|
||||||
|
'Sanana',
|
||||||
|
'Daruba',
|
||||||
|
'Halmahera Barat',
|
||||||
|
'Halmahera Tengah',
|
||||||
|
'Halmahera Utara',
|
||||||
|
'Halmahera Selatan',
|
||||||
|
'Halmahera Timur',
|
||||||
|
'Kepulauan Sula',
|
||||||
|
'Pulau Morotai',
|
||||||
|
'Pulau Taliabu',
|
||||||
|
'Ambon',
|
||||||
|
'Tual',
|
||||||
|
'Masohi',
|
||||||
|
'Piru',
|
||||||
|
'Dobo',
|
||||||
|
'Saumlaki',
|
||||||
|
'Namlea',
|
||||||
|
'Dataran Hunimoa',
|
||||||
|
'Tiakur',
|
||||||
|
'Buru',
|
||||||
|
'Buru Selatan',
|
||||||
|
'Kepulauan Aru',
|
||||||
|
'Maluku Barat Daya',
|
||||||
|
'Maluku Tengah',
|
||||||
|
'Maluku Tenggara',
|
||||||
|
'Maluku Tenggara Barat',
|
||||||
|
'Seram Bagian Barat',
|
||||||
|
'Seram Bagian Timur',
|
||||||
|
'Jayapura',
|
||||||
|
'Merauke',
|
||||||
|
'Biak',
|
||||||
|
'Nabire',
|
||||||
|
'Wamena',
|
||||||
|
'Timika',
|
||||||
|
'Sarmi',
|
||||||
|
'Serui',
|
||||||
|
'Sentani',
|
||||||
|
'Agats',
|
||||||
|
'Asmat',
|
||||||
|
'Biak Numfor',
|
||||||
|
'Boven Digoel',
|
||||||
|
'Deiyai',
|
||||||
|
'Dogiyai',
|
||||||
|
'Intan Jaya',
|
||||||
|
'Jayapura',
|
||||||
|
'Jayawijaya',
|
||||||
|
'Keerom',
|
||||||
|
'Kepulauan Yapen',
|
||||||
|
'Lanny Jaya',
|
||||||
|
'Mamberamo Raya',
|
||||||
|
'Mamberamo Tengah',
|
||||||
|
'Mappi',
|
||||||
|
'Merauke',
|
||||||
|
'Mimika',
|
||||||
|
'Nabire',
|
||||||
|
'Nduga',
|
||||||
|
'Paniai',
|
||||||
|
'Pegunungan Bintang',
|
||||||
|
'Puncak',
|
||||||
|
'Puncak Jaya',
|
||||||
|
'Sarmi',
|
||||||
|
'Supiori',
|
||||||
|
'Tolikara',
|
||||||
|
'Waropen',
|
||||||
|
'Yahukimo',
|
||||||
|
'Yalimo',
|
||||||
|
'Sorong',
|
||||||
|
'Manokwari',
|
||||||
|
'Fak-Fak',
|
||||||
|
'Kaimana',
|
||||||
|
'Teminabuan',
|
||||||
|
'Waisai',
|
||||||
|
'Rasiei',
|
||||||
|
'Bintuni',
|
||||||
|
'Sorong',
|
||||||
|
'Sorong Selatan',
|
||||||
|
'Raja Ampat',
|
||||||
|
'Tambrauw',
|
||||||
|
'Maybrat',
|
||||||
|
'Manokwari',
|
||||||
|
'Manokwari Selatan',
|
||||||
|
'Pegunungan Arfak',
|
||||||
|
'Teluk Bintuni',
|
||||||
|
'Teluk Wondama',
|
||||||
|
'Fakfak',
|
||||||
|
'Kaimana',
|
||||||
|
];
|
|
@ -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/forgot_password_controller.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/signin_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/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 {
|
class AuthControllerBindings extends Bindings {
|
||||||
@override
|
@override
|
||||||
|
|
|
@ -0,0 +1,340 @@
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:sigap/src/features/auth/data/models/user_metadata_model.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/officer_info_controller.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/personal_info_controller.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/selfie_verification_controller.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/unit_info_controller.dart';
|
||||||
|
import 'package:sigap/src/features/daily-ops/data/models/index.dart';
|
||||||
|
import 'package:sigap/src/features/personalization/data/models/index.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/app_routes.dart';
|
||||||
|
import 'package:sigap/src/utils/popups/loaders.dart';
|
||||||
|
|
||||||
|
class FormRegistrationController extends GetxController {
|
||||||
|
static FormRegistrationController get to => Get.find();
|
||||||
|
|
||||||
|
// Role information
|
||||||
|
final Rx<RoleModel?> selectedRole = Rx<RoleModel?>(null);
|
||||||
|
|
||||||
|
// Form steps controllers
|
||||||
|
late final PersonalInfoController personalInfoController;
|
||||||
|
late final IdCardVerificationController idCardVerificationController;
|
||||||
|
late final SelfieVerificationController selfieVerificationController;
|
||||||
|
late final IdentityVerificationController identityController;
|
||||||
|
late final OfficerInfoController? officerInfoController;
|
||||||
|
late final UnitInfoController? unitInfoController;
|
||||||
|
|
||||||
|
// Current step index
|
||||||
|
final RxInt currentStep = 0.obs;
|
||||||
|
|
||||||
|
// Total number of steps (depends on role)
|
||||||
|
late final int totalSteps;
|
||||||
|
|
||||||
|
// User metadata model
|
||||||
|
final Rx<UserMetadataModel> userMetadata = UserMetadataModel().obs;
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
final RxBool isLoading = false.obs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
|
||||||
|
// Get role from arguments
|
||||||
|
final arguments = Get.arguments;
|
||||||
|
if (arguments != null && arguments['role'] != null) {
|
||||||
|
selectedRole.value = arguments['role'] as RoleModel;
|
||||||
|
|
||||||
|
// Initialize userMetadata with the selected role information
|
||||||
|
userMetadata.value = UserMetadataModel(
|
||||||
|
isOfficer: selectedRole.value?.isOfficer ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
_initializeControllers();
|
||||||
|
} else {
|
||||||
|
// Get.snackbar(
|
||||||
|
// 'Error',
|
||||||
|
// 'No role selected. Please go back and select a role.',
|
||||||
|
// snackPosition: SnackPosition.BOTTOM,
|
||||||
|
// backgroundColor: Colors.red,
|
||||||
|
// colorText: Colors.white,
|
||||||
|
// );
|
||||||
|
TLoaders.errorSnackBar(
|
||||||
|
title: 'Error',
|
||||||
|
message: 'No role selected. Please go back and select a role.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedRole.value?.isOfficer == true) {
|
||||||
|
_fetchAvailableUnits();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeControllers() {
|
||||||
|
final isOfficer = selectedRole.value?.isOfficer ?? false;
|
||||||
|
|
||||||
|
// Always initialize personal info controller
|
||||||
|
personalInfoController = Get.put(PersonalInfoController());
|
||||||
|
|
||||||
|
// Initialize ID Card verification controller
|
||||||
|
idCardVerificationController = Get.put(
|
||||||
|
IdCardVerificationController(isOfficer: isOfficer),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize Selfie verification controller
|
||||||
|
selfieVerificationController = Get.put(SelfieVerificationController());
|
||||||
|
|
||||||
|
// Initialize identity verification controller
|
||||||
|
identityController = Get.put(
|
||||||
|
IdentityVerificationController(isOfficer: isOfficer),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isOfficer) {
|
||||||
|
// Initialize officer-specific controllers
|
||||||
|
officerInfoController = Get.put(OfficerInfoController());
|
||||||
|
unitInfoController = Get.put(UnitInfoController());
|
||||||
|
totalSteps = 5; // Personal, ID Card, Selfie, Officer Info, Unit Info
|
||||||
|
} else {
|
||||||
|
// For civilian users
|
||||||
|
officerInfoController = null;
|
||||||
|
unitInfoController = null;
|
||||||
|
totalSteps = 4; // Personal, ID Card, Selfie, Identity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get step titles based on role
|
||||||
|
List<String> getStepTitles() {
|
||||||
|
if (selectedRole.value?.isOfficer ?? false) {
|
||||||
|
return ['Personal', 'ID Card', 'Selfie', 'Officer Info', 'Unit Info'];
|
||||||
|
} else {
|
||||||
|
return ['Personal', 'ID Card', 'Selfie', 'Identity'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchAvailableUnits() async {
|
||||||
|
try {
|
||||||
|
isLoading.value = true;
|
||||||
|
// Here we would fetch units from repository
|
||||||
|
// For now we'll use dummy data
|
||||||
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
|
|
||||||
|
// Update the units in the UnitInfoController
|
||||||
|
if (unitInfoController != null) {
|
||||||
|
// unitInfoController!.availableUnits.value = fetchedUnits;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
TLoaders.errorSnackBar(
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Failed to fetch available units: ${e.toString()}',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate current step
|
||||||
|
bool validateCurrentStep() {
|
||||||
|
switch (currentStep.value) {
|
||||||
|
case 0:
|
||||||
|
return personalInfoController.validate();
|
||||||
|
case 1:
|
||||||
|
return idCardVerificationController.validate();
|
||||||
|
case 2:
|
||||||
|
return selfieVerificationController.validate();
|
||||||
|
case 3:
|
||||||
|
return selectedRole.value?.isOfficer == true
|
||||||
|
? officerInfoController!.validate()
|
||||||
|
: identityController.validate();
|
||||||
|
case 4:
|
||||||
|
return selectedRole.value?.isOfficer == true
|
||||||
|
? unitInfoController!.validate()
|
||||||
|
: true; // Should not reach here for non-officers
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go to next step
|
||||||
|
void nextStep() {
|
||||||
|
if (!validateCurrentStep()) return;
|
||||||
|
|
||||||
|
if (currentStep.value < totalSteps - 1) {
|
||||||
|
currentStep.value++;
|
||||||
|
} else {
|
||||||
|
submitForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearPreviousStepErrors() {
|
||||||
|
switch (currentStep.value) {
|
||||||
|
case 0:
|
||||||
|
personalInfoController.clearErrors();
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
idCardVerificationController.clearErrors();
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
selfieVerificationController.clearErrors();
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
if (selectedRole.value?.isOfficer == true) {
|
||||||
|
officerInfoController!.clearErrors();
|
||||||
|
} else {
|
||||||
|
identityController.clearErrors();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go to previous step
|
||||||
|
void previousStep() {
|
||||||
|
if (currentStep.value > 0) {
|
||||||
|
// Clear previous step errors
|
||||||
|
clearPreviousStepErrors();
|
||||||
|
|
||||||
|
// Decrement step
|
||||||
|
currentStep.value--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go to specific step
|
||||||
|
void goToStep(int step) {
|
||||||
|
if (step >= 0 && step < totalSteps) {
|
||||||
|
// Only allow going to a step if all previous steps are valid
|
||||||
|
bool canProceed = true;
|
||||||
|
for (int i = 0; i < step; i++) {
|
||||||
|
currentStep.value = i;
|
||||||
|
if (!validateCurrentStep()) {
|
||||||
|
canProceed = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canProceed) {
|
||||||
|
currentStep.value = step;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit the complete form
|
||||||
|
Future<void> submitForm() async {
|
||||||
|
// Validate all steps
|
||||||
|
bool isValid = true;
|
||||||
|
for (int i = 0; i < totalSteps; i++) {
|
||||||
|
currentStep.value = i;
|
||||||
|
if (!validateCurrentStep()) {
|
||||||
|
isValid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
// Prepare UserMetadataModel based on role
|
||||||
|
if (selectedRole.value?.isOfficer == true) {
|
||||||
|
// Officer role - create OfficerModel with the data
|
||||||
|
final officerData = OfficerModel(
|
||||||
|
id: '', // Will be assigned by backend
|
||||||
|
unitId: unitInfoController!.unitIdController.text,
|
||||||
|
roleId: selectedRole.value!.id,
|
||||||
|
nrp: officerInfoController!.nrpController.text,
|
||||||
|
name: personalInfoController.nameController.text,
|
||||||
|
rank: officerInfoController!.rankController.text,
|
||||||
|
position: unitInfoController!.positionController.text,
|
||||||
|
phone: personalInfoController.phoneController.text,
|
||||||
|
);
|
||||||
|
|
||||||
|
userMetadata.value = UserMetadataModel(
|
||||||
|
isOfficer: true,
|
||||||
|
name: personalInfoController.nameController.text,
|
||||||
|
phone: personalInfoController.phoneController.text,
|
||||||
|
officerData: officerData,
|
||||||
|
// idCardImagePath: idCardVerificationController.idCardImage.value?.path,
|
||||||
|
// selfieImagePath: selfieVerificationController.selfieImage.value?.path,
|
||||||
|
additionalData: {
|
||||||
|
'address': personalInfoController.addressController.text,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Regular user - create profile-related data
|
||||||
|
userMetadata.value = UserMetadataModel(
|
||||||
|
isOfficer: false,
|
||||||
|
nik: identityController.nikController.text,
|
||||||
|
name: personalInfoController.nameController.text,
|
||||||
|
phone: personalInfoController.phoneController.text,
|
||||||
|
// idCardImagePath: idCardVerificationController.idCardImage.value?.path,
|
||||||
|
// selfieImagePath: selfieVerificationController.selfieImage.value?.path,
|
||||||
|
profileData: ProfileModel(
|
||||||
|
id: '', // Will be assigned by backend
|
||||||
|
userId: '', // Will be assigned by backend
|
||||||
|
nik: identityController.nikController.text,
|
||||||
|
firstName: personalInfoController.firstNameController.text.trim(),
|
||||||
|
lastName: personalInfoController.lastNameController.text.trim(),
|
||||||
|
bio: identityController.bioController.text,
|
||||||
|
birthDate: _parseBirthDate(
|
||||||
|
identityController.birthDateController.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
additionalData: {
|
||||||
|
'address': personalInfoController.addressController.text,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to the signup screen with the prepared metadata
|
||||||
|
Get.toNamed(
|
||||||
|
AppRoutes.signUp,
|
||||||
|
arguments: {
|
||||||
|
'userMetadata': userMetadata.value,
|
||||||
|
'role': selectedRole.value,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
Get.toNamed(
|
||||||
|
AppRoutes.stateScreen,
|
||||||
|
arguments: {
|
||||||
|
'type': 'error',
|
||||||
|
'title': 'Data Preparation Failed',
|
||||||
|
'message':
|
||||||
|
'There was an error preparing your profile: ${e.toString()}',
|
||||||
|
'buttonText': 'Try Again',
|
||||||
|
'onButtonPressed': () => Get.back(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse birth date string to DateTime
|
||||||
|
DateTime? _parseBirthDate(String dateStr) {
|
||||||
|
try {
|
||||||
|
// Try to parse in format YYYY-MM-DD
|
||||||
|
if (dateStr.isEmpty) return null;
|
||||||
|
|
||||||
|
// Add validation for different date formats as needed
|
||||||
|
if (dateStr.contains('-')) {
|
||||||
|
return DateTime.parse(dateStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle other formats like DD/MM/YYYY
|
||||||
|
if (dateStr.contains('/')) {
|
||||||
|
final parts = dateStr.split('/');
|
||||||
|
if (parts.length == 3) {
|
||||||
|
final day = int.parse(parts[0]);
|
||||||
|
final month = int.parse(parts[1]);
|
||||||
|
final year = int.parse(parts[2]);
|
||||||
|
return DateTime(year, month, day);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,905 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:get/get.dart';
|
|
||||||
import 'package:image_picker/image_picker.dart';
|
|
||||||
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
|
|
||||||
import 'package:sigap/src/features/auth/data/models/user_metadata_model.dart';
|
|
||||||
import 'package:sigap/src/features/daily-ops/data/models/index.dart';
|
|
||||||
import 'package:sigap/src/features/personalization/data/models/index.dart';
|
|
||||||
import 'package:sigap/src/utils/constants/app_routes.dart';
|
|
||||||
import 'package:sigap/src/utils/popups/loaders.dart';
|
|
||||||
import 'package:sigap/src/utils/validators/validation.dart';
|
|
||||||
|
|
||||||
class FormRegistrationController extends GetxController {
|
|
||||||
static FormRegistrationController get to => Get.find();
|
|
||||||
|
|
||||||
// Role information
|
|
||||||
final Rx<RoleModel?> selectedRole = Rx<RoleModel?>(null);
|
|
||||||
|
|
||||||
// Current step index
|
|
||||||
final RxInt currentStep = 0.obs;
|
|
||||||
|
|
||||||
// Form keys for each step (dynamic based on role)
|
|
||||||
late List<GlobalKey<FormState>> stepFormKeys;
|
|
||||||
|
|
||||||
// Common information (for all roles)
|
|
||||||
final firstNameController = TextEditingController();
|
|
||||||
final lastNameController = TextEditingController();
|
|
||||||
final nameController = TextEditingController(); // For combined name
|
|
||||||
final phoneController = TextEditingController();
|
|
||||||
final addressController = TextEditingController();
|
|
||||||
|
|
||||||
// Viewer-specific fields
|
|
||||||
final nikController = TextEditingController();
|
|
||||||
final bioController = TextEditingController();
|
|
||||||
final birthDateController = TextEditingController();
|
|
||||||
|
|
||||||
// Officer-specific fields
|
|
||||||
final nrpController = TextEditingController();
|
|
||||||
final rankController = TextEditingController();
|
|
||||||
final positionController = TextEditingController();
|
|
||||||
final unitIdController = TextEditingController();
|
|
||||||
|
|
||||||
// User metadata model
|
|
||||||
final Rx<UserMetadataModel> userMetadata = UserMetadataModel().obs;
|
|
||||||
|
|
||||||
// Error states - Common
|
|
||||||
final RxString firstNameError = ''.obs;
|
|
||||||
final RxString lastNameError = ''.obs;
|
|
||||||
final RxString nameError = ''.obs;
|
|
||||||
final RxString phoneError = ''.obs;
|
|
||||||
final RxString addressError = ''.obs;
|
|
||||||
|
|
||||||
// Error states - Viewer
|
|
||||||
final RxString nikError = ''.obs;
|
|
||||||
final RxString bioError = ''.obs;
|
|
||||||
final RxString birthDateError = ''.obs;
|
|
||||||
|
|
||||||
// Error states - Officer
|
|
||||||
final RxString nrpError = ''.obs;
|
|
||||||
final RxString rankError = ''.obs;
|
|
||||||
final RxString positionError = ''.obs;
|
|
||||||
final RxString unitIdError = ''.obs;
|
|
||||||
|
|
||||||
// Loading state
|
|
||||||
final RxBool isLoading = false.obs;
|
|
||||||
|
|
||||||
// Available units for officer role
|
|
||||||
final RxList<UnitModel> availableUnits = <UnitModel>[].obs;
|
|
||||||
|
|
||||||
// ID Card variables
|
|
||||||
final Rx<XFile?> idCardImage = Rx<XFile?>(null);
|
|
||||||
final RxString idCardError = RxString('');
|
|
||||||
final RxBool isVerifying = RxBool(false);
|
|
||||||
final RxBool isVerified = RxBool(false);
|
|
||||||
final RxString verificationMessage = RxString('');
|
|
||||||
|
|
||||||
// Face verification variables
|
|
||||||
final Rx<XFile?> selfieImage = Rx<XFile?>(null);
|
|
||||||
final RxString selfieError = RxString('');
|
|
||||||
final RxBool isVerifyingFace = RxBool(false);
|
|
||||||
final RxBool isFaceVerified = RxBool(false);
|
|
||||||
final RxString faceVerificationMessage = RxString('');
|
|
||||||
final RxBool isLivenessCheckPassed = RxBool(false);
|
|
||||||
final RxBool isPerformingLivenessCheck = RxBool(false);
|
|
||||||
|
|
||||||
// Azure OCR service
|
|
||||||
final AzureOCRService _ocrService = AzureOCRService();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void onInit() {
|
|
||||||
super.onInit();
|
|
||||||
|
|
||||||
// Add listeners to first name and last name controllers to update the combined name
|
|
||||||
firstNameController.addListener(_updateCombinedName);
|
|
||||||
lastNameController.addListener(_updateCombinedName);
|
|
||||||
|
|
||||||
// Get role from arguments
|
|
||||||
final arguments = Get.arguments;
|
|
||||||
if (arguments != null && arguments['role'] != null) {
|
|
||||||
selectedRole.value = arguments['role'] as RoleModel;
|
|
||||||
|
|
||||||
// Initialize userMetadata with the selected role information
|
|
||||||
userMetadata.value = UserMetadataModel(
|
|
||||||
isOfficer: selectedRole.value?.isOfficer ?? false,
|
|
||||||
);
|
|
||||||
|
|
||||||
_initializeBasedOnRole();
|
|
||||||
} else {
|
|
||||||
Get.snackbar(
|
|
||||||
'Error',
|
|
||||||
'No role selected. Please go back and select a role.',
|
|
||||||
snackPosition: SnackPosition.BOTTOM,
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
colorText: Colors.white,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedRole.value?.isOfficer == true) {
|
|
||||||
_fetchAvailableUnits();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the combined name when either first or last name changes
|
|
||||||
void _updateCombinedName() {
|
|
||||||
final firstName = firstNameController.text.trim();
|
|
||||||
final lastName = lastNameController.text.trim();
|
|
||||||
|
|
||||||
if (firstName.isEmpty && lastName.isEmpty) {
|
|
||||||
nameController.text = '';
|
|
||||||
} else if (lastName.isEmpty) {
|
|
||||||
nameController.text = firstName;
|
|
||||||
} else if (firstName.isEmpty) {
|
|
||||||
nameController.text = lastName;
|
|
||||||
} else {
|
|
||||||
nameController.text = '$firstName $lastName';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _initializeBasedOnRole() {
|
|
||||||
if (selectedRole.value?.isOfficer == true) {
|
|
||||||
stepFormKeys = List.generate(3, (_) => GlobalKey<FormState>());
|
|
||||||
} else {
|
|
||||||
// Viewer role or default
|
|
||||||
stepFormKeys = List.generate(2, (_) => GlobalKey<FormState>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _fetchAvailableUnits() async {
|
|
||||||
try {
|
|
||||||
isLoading.value = true;
|
|
||||||
// Here we would fetch units from repository
|
|
||||||
// For now we'll use dummy data
|
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
|
||||||
} catch (e) {
|
|
||||||
TLoaders.errorSnackBar(
|
|
||||||
title: 'Error',
|
|
||||||
message: 'Failed to fetch available units: ${e.toString()}',
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void onClose() {
|
|
||||||
// Remove listeners
|
|
||||||
firstNameController.removeListener(_updateCombinedName);
|
|
||||||
lastNameController.removeListener(_updateCombinedName);
|
|
||||||
|
|
||||||
// Dispose all controllers
|
|
||||||
firstNameController.dispose();
|
|
||||||
lastNameController.dispose();
|
|
||||||
nameController.dispose();
|
|
||||||
phoneController.dispose();
|
|
||||||
addressController.dispose();
|
|
||||||
|
|
||||||
nikController.dispose();
|
|
||||||
bioController.dispose();
|
|
||||||
birthDateController.dispose();
|
|
||||||
|
|
||||||
nrpController.dispose();
|
|
||||||
rankController.dispose();
|
|
||||||
positionController.dispose();
|
|
||||||
unitIdController.dispose();
|
|
||||||
|
|
||||||
super.onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate current step
|
|
||||||
bool validateCurrentStep() {
|
|
||||||
clearErrors(); // Clear previous errors
|
|
||||||
|
|
||||||
final currentFormKey = stepFormKeys[currentStep.value];
|
|
||||||
if (currentFormKey.currentState?.validate() ?? false) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If validation failed using the form's built-in validation,
|
|
||||||
// we may want to perform additional validation and set error messages
|
|
||||||
if (currentStep.value == 0) {
|
|
||||||
validatePersonalInfo();
|
|
||||||
} else if (currentStep.value == 1) {
|
|
||||||
if (selectedRole.value?.isOfficer == true) {
|
|
||||||
validateOfficerInfo();
|
|
||||||
} else {
|
|
||||||
validateEmergencyContact();
|
|
||||||
}
|
|
||||||
} else if (currentStep.value == 2 &&
|
|
||||||
selectedRole.value?.isOfficer == true) {
|
|
||||||
validateOfficerAdditionalInfo();
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
void clearErrors() {
|
|
||||||
// Clear common errors
|
|
||||||
firstNameError.value = '';
|
|
||||||
lastNameError.value = '';
|
|
||||||
nameError.value = '';
|
|
||||||
phoneError.value = '';
|
|
||||||
addressError.value = '';
|
|
||||||
|
|
||||||
// Clear viewer-specific errors
|
|
||||||
nikError.value = '';
|
|
||||||
bioError.value = '';
|
|
||||||
birthDateError.value = '';
|
|
||||||
|
|
||||||
// Clear officer-specific errors
|
|
||||||
nrpError.value = '';
|
|
||||||
rankError.value = '';
|
|
||||||
positionError.value = '';
|
|
||||||
unitIdError.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
bool validatePersonalInfo() {
|
|
||||||
bool isValid = true;
|
|
||||||
|
|
||||||
final firstNameValidation = TValidators.validateUserInput(
|
|
||||||
'First name',
|
|
||||||
firstNameController.text,
|
|
||||||
50,
|
|
||||||
);
|
|
||||||
if (firstNameValidation != null) {
|
|
||||||
firstNameError.value = firstNameValidation;
|
|
||||||
isValid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
final lastNameValidation = TValidators.validateUserInput(
|
|
||||||
'Last name',
|
|
||||||
lastNameController.text,
|
|
||||||
50,
|
|
||||||
required: false, // Last name can be optional
|
|
||||||
);
|
|
||||||
if (lastNameValidation != null) {
|
|
||||||
lastNameError.value = lastNameValidation;
|
|
||||||
isValid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
final phoneValidation = TValidators.validatePhoneNumber(
|
|
||||||
phoneController.text,
|
|
||||||
);
|
|
||||||
if (phoneValidation != null) {
|
|
||||||
phoneError.value = phoneValidation;
|
|
||||||
isValid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
final addressValidation = TValidators.validateUserInput(
|
|
||||||
'Address',
|
|
||||||
addressController.text,
|
|
||||||
255,
|
|
||||||
);
|
|
||||||
if (addressValidation != null) {
|
|
||||||
addressError.value = addressValidation;
|
|
||||||
isValid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return isValid;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool validateEmergencyContact() {
|
|
||||||
bool isValid = true;
|
|
||||||
|
|
||||||
final nikValidation = TValidators.validateUserInput(
|
|
||||||
'NIK',
|
|
||||||
nikController.text,
|
|
||||||
16,
|
|
||||||
);
|
|
||||||
if (nikValidation != null) {
|
|
||||||
nikError.value = nikValidation;
|
|
||||||
isValid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bio can be optional, so we validate with required: false
|
|
||||||
final bioValidation = TValidators.validateUserInput(
|
|
||||||
'Bio',
|
|
||||||
bioController.text,
|
|
||||||
255,
|
|
||||||
required: false,
|
|
||||||
);
|
|
||||||
if (bioValidation != null) {
|
|
||||||
bioError.value = bioValidation;
|
|
||||||
isValid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Birth date validation
|
|
||||||
final birthDateValidation = TValidators.validateUserInput(
|
|
||||||
'Birth Date',
|
|
||||||
birthDateController.text,
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
if (birthDateValidation != null) {
|
|
||||||
birthDateError.value = birthDateValidation;
|
|
||||||
isValid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return isValid;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool validateOfficerInfo() {
|
|
||||||
bool isValid = true;
|
|
||||||
|
|
||||||
final nrpValidation = TValidators.validateUserInput(
|
|
||||||
'NRP',
|
|
||||||
nrpController.text,
|
|
||||||
50,
|
|
||||||
);
|
|
||||||
if (nrpValidation != null) {
|
|
||||||
nrpError.value = nrpValidation;
|
|
||||||
isValid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
final rankValidation = TValidators.validateUserInput(
|
|
||||||
'Rank',
|
|
||||||
rankController.text,
|
|
||||||
50,
|
|
||||||
);
|
|
||||||
if (rankValidation != null) {
|
|
||||||
rankError.value = rankValidation;
|
|
||||||
isValid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return isValid;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool validateOfficerAdditionalInfo() {
|
|
||||||
bool isValid = true;
|
|
||||||
|
|
||||||
final positionValidation = TValidators.validateUserInput(
|
|
||||||
'Position',
|
|
||||||
positionController.text,
|
|
||||||
100,
|
|
||||||
);
|
|
||||||
if (positionValidation != null) {
|
|
||||||
positionError.value = positionValidation;
|
|
||||||
isValid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (unitIdController.text.isEmpty) {
|
|
||||||
unitIdError.value = 'Please select a unit';
|
|
||||||
isValid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return isValid;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pick ID Card Image
|
|
||||||
Future<void> pickIdCardImage(ImageSource source) async {
|
|
||||||
try {
|
|
||||||
final ImagePicker picker = ImagePicker();
|
|
||||||
final XFile? image = await picker.pickImage(
|
|
||||||
source: source,
|
|
||||||
imageQuality: 80,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (image != null) {
|
|
||||||
idCardImage.value = image;
|
|
||||||
idCardError.value = '';
|
|
||||||
// Reset verification status when a new image is picked
|
|
||||||
isVerified.value = false;
|
|
||||||
verificationMessage.value = '';
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
idCardError.value = 'Failed to pick image: $e';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Take or pick selfie image
|
|
||||||
Future<void> pickSelfieImage(ImageSource source, bool isLivenessCheck) async {
|
|
||||||
try {
|
|
||||||
final ImagePicker picker = ImagePicker();
|
|
||||||
final XFile? image = await picker.pickImage(
|
|
||||||
source: source,
|
|
||||||
preferredCameraDevice: CameraDevice.front,
|
|
||||||
imageQuality: 80,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (image != null) {
|
|
||||||
selfieImage.value = image;
|
|
||||||
selfieError.value = '';
|
|
||||||
|
|
||||||
// Reset face verification status
|
|
||||||
isFaceVerified.value = false;
|
|
||||||
faceVerificationMessage.value = '';
|
|
||||||
|
|
||||||
// If this was taken for liveness check, perform the check
|
|
||||||
if (isLivenessCheck && source == ImageSource.camera) {
|
|
||||||
await performLivenessCheck();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
selfieError.value = 'Failed to capture selfie: $e';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform liveness check on selfie
|
|
||||||
Future<void> performLivenessCheck() async {
|
|
||||||
if (selfieImage.value == null) {
|
|
||||||
selfieError.value = 'Please take a selfie first';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
isPerformingLivenessCheck.value = true;
|
|
||||||
|
|
||||||
// Call liveness detection service
|
|
||||||
final result = await _ocrService.performLivenessCheck(selfieImage.value!);
|
|
||||||
|
|
||||||
isLivenessCheckPassed.value = result['isLive'] ?? false;
|
|
||||||
faceVerificationMessage.value = result['message'] ?? '';
|
|
||||||
|
|
||||||
// If liveness check failed, clear the selfie image to force retake
|
|
||||||
if (!isLivenessCheckPassed.value) {
|
|
||||||
selfieError.value = 'Liveness check failed. Please retake your selfie.';
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
isLivenessCheckPassed.value = false;
|
|
||||||
faceVerificationMessage.value = 'Liveness check failed: ${e.toString()}';
|
|
||||||
selfieError.value = 'Error during liveness check: ${e.toString()}';
|
|
||||||
} finally {
|
|
||||||
isPerformingLivenessCheck.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear ID Card Image
|
|
||||||
void clearIdCardImage() {
|
|
||||||
idCardImage.value = null;
|
|
||||||
idCardError.value = '';
|
|
||||||
isVerified.value = false;
|
|
||||||
verificationMessage.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear Selfie Image
|
|
||||||
void clearSelfieImage() {
|
|
||||||
selfieImage.value = null;
|
|
||||||
selfieError.value = '';
|
|
||||||
isFaceVerified.value = false;
|
|
||||||
faceVerificationMessage.value = '';
|
|
||||||
isLivenessCheckPassed.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify ID Card using OCR
|
|
||||||
Future<void> verifyIdCardWithOCR() async {
|
|
||||||
if (idCardImage.value == null) {
|
|
||||||
idCardError.value = 'Please upload an ID card image first';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
isVerifying.value = true;
|
|
||||||
final isOfficer = selectedRole.value?.isOfficer ?? false;
|
|
||||||
|
|
||||||
// Determine ID card type based on role
|
|
||||||
final idCardType = isOfficer ? 'KTA' : 'KTP';
|
|
||||||
|
|
||||||
// Call Azure OCR service with the appropriate ID type
|
|
||||||
final result = await _ocrService.processIdCard(
|
|
||||||
idCardImage.value!,
|
|
||||||
isOfficer,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Validate if all required fields are present in the image
|
|
||||||
if (!_ocrService.validateRequiredFields(result, isOfficer)) {
|
|
||||||
isVerified.value = false;
|
|
||||||
String missingFields = _ocrService.getMissingFieldsDescription(
|
|
||||||
result,
|
|
||||||
isOfficer,
|
|
||||||
);
|
|
||||||
verificationMessage.value =
|
|
||||||
'Invalid $idCardType image. The following information could not be detected: $missingFields. Please upload a clearer image of your ID card.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare OCR results with user input
|
|
||||||
final bool isMatch =
|
|
||||||
isOfficer ? _verifyKtaResults(result) : _verifyKtpResults(result);
|
|
||||||
|
|
||||||
isVerified.value = isMatch;
|
|
||||||
verificationMessage.value =
|
|
||||||
isMatch
|
|
||||||
? '$idCardType verification successful! Your information matches.'
|
|
||||||
: 'Verification failed. Please ensure your information matches your $idCardType or upload a clearer image.';
|
|
||||||
} catch (e) {
|
|
||||||
isVerified.value = false;
|
|
||||||
verificationMessage.value = 'OCR processing failed: ${e.toString()}';
|
|
||||||
} finally {
|
|
||||||
isVerifying.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify selfie with ID card
|
|
||||||
Future<void> verifyFaceMatch() async {
|
|
||||||
if (idCardImage.value == null) {
|
|
||||||
idCardError.value = 'Please upload an ID card image first';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selfieImage.value == null) {
|
|
||||||
selfieError.value = 'Please take a selfie first';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isLivenessCheckPassed.value) {
|
|
||||||
selfieError.value = 'Please complete the liveness check first';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
isVerifyingFace.value = true;
|
|
||||||
|
|
||||||
// Compare face in ID card with selfie face
|
|
||||||
final result = await _ocrService.verifyFace(
|
|
||||||
idCardImage.value!,
|
|
||||||
selfieImage.value!,
|
|
||||||
);
|
|
||||||
|
|
||||||
isFaceVerified.value = result['isMatch'] ?? false;
|
|
||||||
faceVerificationMessage.value = result['message'] ?? '';
|
|
||||||
} catch (e) {
|
|
||||||
isFaceVerified.value = false;
|
|
||||||
faceVerificationMessage.value =
|
|
||||||
'Face verification failed: ${e.toString()}';
|
|
||||||
} finally {
|
|
||||||
isVerifyingFace.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare OCR results with user input for KTP
|
|
||||||
bool _verifyKtpResults(Map<String, String> ocrResults) {
|
|
||||||
int matchCount = 0;
|
|
||||||
int totalFields = 0;
|
|
||||||
|
|
||||||
// Check name matches (case insensitive, accounting for name formatting differences)
|
|
||||||
String fullName =
|
|
||||||
'${firstNameController.text} ${lastNameController.text}'
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
if (ocrResults.containsKey('nama') && fullName.isNotEmpty) {
|
|
||||||
totalFields++;
|
|
||||||
String ocrName = ocrResults['nama']!.toLowerCase();
|
|
||||||
// Use fuzzy matching since OCR might not be perfect
|
|
||||||
if (ocrName.contains(firstNameController.text.toLowerCase()) ||
|
|
||||||
fullName.contains(ocrName)) {
|
|
||||||
matchCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check NIK matches (exact match required for numbers)
|
|
||||||
if (ocrResults.containsKey('nik') && nikController.text.isNotEmpty) {
|
|
||||||
totalFields++;
|
|
||||||
// Clean up any spaces or special characters from OCR result
|
|
||||||
String ocrNik = ocrResults['nik']!.replaceAll(RegExp(r'[^0-9]'), '');
|
|
||||||
if (ocrNik == nikController.text) {
|
|
||||||
matchCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check birth date (flexible format matching)
|
|
||||||
if (ocrResults.containsKey('tanggal_lahir') &&
|
|
||||||
birthDateController.text.isNotEmpty) {
|
|
||||||
totalFields++;
|
|
||||||
// Convert both to a common format for comparison
|
|
||||||
String userDate = _normalizeDateFormat(birthDateController.text);
|
|
||||||
String ocrDate = _normalizeDateFormat(ocrResults['tanggal_lahir']!);
|
|
||||||
if (userDate == ocrDate) {
|
|
||||||
matchCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check address (partial matching is acceptable due to OCR limitations and address complexity)
|
|
||||||
if (ocrResults.containsKey('alamat') && addressController.text.isNotEmpty) {
|
|
||||||
totalFields++;
|
|
||||||
String userAddress = addressController.text.toLowerCase();
|
|
||||||
String ocrAddress = ocrResults['alamat']!.toLowerCase();
|
|
||||||
|
|
||||||
// Check if there's significant overlap between the two addresses
|
|
||||||
List<String> userAddressParts = userAddress.split(' ');
|
|
||||||
int addressMatchCount = 0;
|
|
||||||
|
|
||||||
for (String part in userAddressParts) {
|
|
||||||
if (part.length > 3 && ocrAddress.contains(part)) {
|
|
||||||
addressMatchCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (addressMatchCount >= userAddressParts.length * 0.5) {
|
|
||||||
matchCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Require at least 60% of the fields to match
|
|
||||||
return totalFields > 0 && (matchCount / totalFields) >= 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare OCR results with user input for KTA (Officer ID)
|
|
||||||
bool _verifyKtaResults(Map<String, String> ocrResults) {
|
|
||||||
int matchCount = 0;
|
|
||||||
int totalFields = 0;
|
|
||||||
|
|
||||||
// Check name matches
|
|
||||||
String fullName =
|
|
||||||
'${firstNameController.text} ${lastNameController.text}'
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
if (ocrResults.containsKey('nama') && fullName.isNotEmpty) {
|
|
||||||
totalFields++;
|
|
||||||
String ocrName = ocrResults['nama']!.toLowerCase();
|
|
||||||
if (ocrName.contains(firstNameController.text.toLowerCase()) ||
|
|
||||||
fullName.contains(ocrName)) {
|
|
||||||
matchCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check rank matches
|
|
||||||
if (ocrResults.containsKey('pangkat') && rankController.text.isNotEmpty) {
|
|
||||||
totalFields++;
|
|
||||||
String ocrRank = ocrResults['pangkat']!.toLowerCase();
|
|
||||||
String userRank = rankController.text.toLowerCase();
|
|
||||||
if (ocrRank.contains(userRank) || userRank.contains(ocrRank)) {
|
|
||||||
matchCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check NRP matches
|
|
||||||
if (ocrResults.containsKey('nrp') && nrpController.text.isNotEmpty) {
|
|
||||||
totalFields++;
|
|
||||||
String ocrNrp = ocrResults['nrp']!.replaceAll(
|
|
||||||
RegExp(r'[^0-9a-zA-Z]'),
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
String userNrp = nrpController.text.replaceAll(
|
|
||||||
RegExp(r'[^0-9a-zA-Z]'),
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
if (ocrNrp.contains(userNrp) || userNrp.contains(ocrNrp)) {
|
|
||||||
matchCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check unit matches
|
|
||||||
if (ocrResults.containsKey('unit') && unitIdController.text.isNotEmpty) {
|
|
||||||
totalFields++;
|
|
||||||
String ocrUnit = ocrResults['unit']!.toLowerCase();
|
|
||||||
|
|
||||||
// Find the matching unit from available units
|
|
||||||
final selectedUnit = availableUnits.firstWhere(
|
|
||||||
(unit) => unit.codeUnit == unitIdController.text,
|
|
||||||
orElse: () => UnitModel(),
|
|
||||||
);
|
|
||||||
|
|
||||||
String userUnit = selectedUnit.name.toLowerCase();
|
|
||||||
|
|
||||||
if (ocrUnit.contains(userUnit) || userUnit.contains(ocrUnit)) {
|
|
||||||
matchCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check birth date if available
|
|
||||||
if (ocrResults.containsKey('tanggal_lahir') &&
|
|
||||||
birthDateController.text.isNotEmpty) {
|
|
||||||
totalFields++;
|
|
||||||
String userDate = _normalizeDateFormat(birthDateController.text);
|
|
||||||
String ocrDate = _normalizeDateFormat(ocrResults['tanggal_lahir']!);
|
|
||||||
if (userDate == ocrDate) {
|
|
||||||
matchCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Require at least 60% of the fields to match
|
|
||||||
return totalFields > 0 && (matchCount / totalFields) >= 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper method to normalize date formats for comparison
|
|
||||||
String _normalizeDateFormat(String dateStr) {
|
|
||||||
// Remove non-numeric characters
|
|
||||||
String numericOnly = dateStr.replaceAll(RegExp(r'[^0-9]'), '');
|
|
||||||
|
|
||||||
// Try to extract year, month, day in a standardized format
|
|
||||||
if (numericOnly.length >= 8) {
|
|
||||||
// Assume YYYYMMDD format
|
|
||||||
return numericOnly.substring(0, 8);
|
|
||||||
} else {
|
|
||||||
return numericOnly;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override existing nextStep method to include ID card and face verification
|
|
||||||
void nextStep() {
|
|
||||||
// For the identity step, ensure both ID card and face verification are completed
|
|
||||||
if (currentStep.value == 1 &&
|
|
||||||
!isLoading.value &&
|
|
||||||
selectedRole.value != null) {
|
|
||||||
if (!_validateCurrentStep()) return;
|
|
||||||
|
|
||||||
// Check if ID card image is uploaded
|
|
||||||
if (idCardImage.value == null) {
|
|
||||||
final idCardType = selectedRole.value!.isOfficer ? 'KTA' : 'KTP';
|
|
||||||
idCardError.value =
|
|
||||||
'Please upload your $idCardType image for verification';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if ID card is verified
|
|
||||||
if (!isVerified.value) {
|
|
||||||
idCardError.value = 'Please verify your ID card first';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if selfie is captured
|
|
||||||
if (selfieImage.value == null) {
|
|
||||||
selfieError.value = 'Please take a selfie for face verification';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if liveness check is passed
|
|
||||||
if (!isLivenessCheckPassed.value) {
|
|
||||||
selfieError.value = 'Please complete the liveness check';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if face is verified
|
|
||||||
if (!isFaceVerified.value) {
|
|
||||||
selfieError.value = 'Please complete face verification';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Proceed with the original logic
|
|
||||||
if (currentStep.value < stepFormKeys.length - 1) {
|
|
||||||
currentStep.value++;
|
|
||||||
} else {
|
|
||||||
submitForm();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Original nextStep logic for other steps
|
|
||||||
if (!validateCurrentStep()) return;
|
|
||||||
|
|
||||||
if (currentStep.value < stepFormKeys.length - 1) {
|
|
||||||
currentStep.value++;
|
|
||||||
} else {
|
|
||||||
submitForm();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Go to previous step
|
|
||||||
void previousStep() {
|
|
||||||
if (currentStep.value > 0) {
|
|
||||||
currentStep.value--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Go to specific step
|
|
||||||
void goToStep(int step) {
|
|
||||||
if (step >= 0 && step < stepFormKeys.length) {
|
|
||||||
// Only allow going to a step if all previous steps are valid
|
|
||||||
bool canProceed = true;
|
|
||||||
for (int i = 0; i < step; i++) {
|
|
||||||
clearErrors(); // Clear errors before validating
|
|
||||||
final formKey = stepFormKeys[i];
|
|
||||||
if (!(formKey.currentState?.validate() ?? false)) {
|
|
||||||
canProceed = false;
|
|
||||||
currentStep.value = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canProceed) {
|
|
||||||
currentStep.value = step;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Submit the complete form
|
|
||||||
Future<void> submitForm() async {
|
|
||||||
// Validate all steps
|
|
||||||
bool isValid = true;
|
|
||||||
for (int i = 0; i < stepFormKeys.length; i++) {
|
|
||||||
final formKey = stepFormKeys[i];
|
|
||||||
if (!(formKey.currentState?.validate() ?? false)) {
|
|
||||||
isValid = false;
|
|
||||||
currentStep.value = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isValid) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
isLoading.value = true;
|
|
||||||
|
|
||||||
// Prepare UserMetadataModel based on role
|
|
||||||
if (selectedRole.value?.isOfficer == true) {
|
|
||||||
// Officer role - create OfficerModel with the data
|
|
||||||
final officerData = OfficerModel(
|
|
||||||
id: '', // Will be assigned by backend
|
|
||||||
unitId: unitIdController.text,
|
|
||||||
roleId: selectedRole.value!.id,
|
|
||||||
nrp: nrpController.text,
|
|
||||||
name: nameController.text, // Use the combined name here
|
|
||||||
rank: rankController.text,
|
|
||||||
position: positionController.text,
|
|
||||||
phone: phoneController.text,
|
|
||||||
);
|
|
||||||
|
|
||||||
userMetadata.value = UserMetadataModel(
|
|
||||||
isOfficer: true,
|
|
||||||
name: nameController.text, // Use the combined name
|
|
||||||
phone: phoneController.text,
|
|
||||||
officerData: officerData,
|
|
||||||
additionalData: {'address': addressController.text},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Regular user - create profile-related data
|
|
||||||
userMetadata.value = UserMetadataModel(
|
|
||||||
isOfficer: false,
|
|
||||||
nik: nikController.text,
|
|
||||||
name: nameController.text, // Use the combined name
|
|
||||||
phone: phoneController.text,
|
|
||||||
profileData: ProfileModel(
|
|
||||||
id: '', // Will be assigned by backend
|
|
||||||
userId: '', // Will be assigned by backend
|
|
||||||
nik: nikController.text,
|
|
||||||
firstName: firstNameController.text.trim(),
|
|
||||||
lastName: lastNameController.text.trim(),
|
|
||||||
bio: bioController.text, // Add the bio field
|
|
||||||
birthDate: _parseBirthDate(
|
|
||||||
birthDateController.text,
|
|
||||||
), // Parse birth date
|
|
||||||
),
|
|
||||||
additionalData: {'address': addressController.text},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate to the signup screen with the prepared metadata
|
|
||||||
Get.toNamed(
|
|
||||||
AppRoutes.signUp,
|
|
||||||
arguments: {
|
|
||||||
'userMetadata': userMetadata.value,
|
|
||||||
'role': selectedRole.value,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
Get.toNamed(
|
|
||||||
AppRoutes.stateScreen,
|
|
||||||
arguments: {
|
|
||||||
'type': 'error',
|
|
||||||
'title': 'Data Preparation Failed',
|
|
||||||
'message':
|
|
||||||
'There was an error preparing your profile: ${e.toString()}',
|
|
||||||
'buttonText': 'Try Again',
|
|
||||||
'onButtonPressed': () => Get.back(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse birth date string to DateTime
|
|
||||||
DateTime? _parseBirthDate(String dateStr) {
|
|
||||||
try {
|
|
||||||
// Try to parse in format YYYY-MM-DD
|
|
||||||
if (dateStr.isEmpty) return null;
|
|
||||||
|
|
||||||
// Add validation for different date formats as needed
|
|
||||||
if (dateStr.contains('-')) {
|
|
||||||
return DateTime.parse(dateStr);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle other formats like DD/MM/YYYY
|
|
||||||
if (dateStr.contains('/')) {
|
|
||||||
final parts = dateStr.split('/');
|
|
||||||
if (parts.length == 3) {
|
|
||||||
final day = int.parse(parts[0]);
|
|
||||||
final month = int.parse(parts[1]);
|
|
||||||
final year = int.parse(parts[2]);
|
|
||||||
return DateTime(year, month, day);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,173 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
|
||||||
|
|
||||||
|
class IdCardVerificationController extends GetxController {
|
||||||
|
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||||
|
final AzureOCRService _ocrService = AzureOCRService();
|
||||||
|
final bool isOfficer;
|
||||||
|
|
||||||
|
IdCardVerificationController({required this.isOfficer});
|
||||||
|
|
||||||
|
// ID Card variables
|
||||||
|
final Rx<XFile?> idCardImage = Rx<XFile?>(null);
|
||||||
|
final RxString idCardError = RxString('');
|
||||||
|
final RxBool isVerifying = RxBool(false);
|
||||||
|
final RxBool isIdCardValid = RxBool(false);
|
||||||
|
final RxString idCardValidationMessage = RxString('');
|
||||||
|
|
||||||
|
// Loading states for image uploading
|
||||||
|
final RxBool isUploadingIdCard = RxBool(false);
|
||||||
|
|
||||||
|
// Confirmation status
|
||||||
|
final RxBool hasConfirmedIdCard = RxBool(false);
|
||||||
|
|
||||||
|
bool validate() {
|
||||||
|
clearErrors();
|
||||||
|
|
||||||
|
// For this step, we just need to ensure ID card is uploaded and validated
|
||||||
|
bool isValid = true;
|
||||||
|
|
||||||
|
if (idCardImage.value == null) {
|
||||||
|
final idCardType = isOfficer ? 'KTA' : 'KTP';
|
||||||
|
idCardError.value = 'Please upload your $idCardType image';
|
||||||
|
isValid = false;
|
||||||
|
} else if (!isIdCardValid.value) {
|
||||||
|
idCardError.value = 'Your ID card image is not valid';
|
||||||
|
isValid = false;
|
||||||
|
} else if (!hasConfirmedIdCard.value) {
|
||||||
|
idCardError.value = 'Please confirm your ID card image';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearErrors() {
|
||||||
|
idCardError.value = '';
|
||||||
|
idCardValidationMessage.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick ID Card Image
|
||||||
|
Future<void> pickIdCardImage(ImageSource source) async {
|
||||||
|
try {
|
||||||
|
isUploadingIdCard.value = true;
|
||||||
|
hasConfirmedIdCard.value =
|
||||||
|
false; // Reset confirmation whenever image changes
|
||||||
|
|
||||||
|
final ImagePicker picker = ImagePicker();
|
||||||
|
final XFile? image = await picker.pickImage(
|
||||||
|
source: source,
|
||||||
|
imageQuality: 80,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (image != null) {
|
||||||
|
// Add artificial delay to show loading state
|
||||||
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
|
idCardImage.value = image;
|
||||||
|
idCardError.value = '';
|
||||||
|
|
||||||
|
// Initial validation of the ID card
|
||||||
|
await validateIdCardImage();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
idCardError.value = 'Failed to pick image: $e';
|
||||||
|
isIdCardValid.value = false;
|
||||||
|
} finally {
|
||||||
|
isUploadingIdCard.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial validation of ID card image
|
||||||
|
Future<void> validateIdCardImage() async {
|
||||||
|
// Clear previous validation messages
|
||||||
|
clearErrors();
|
||||||
|
|
||||||
|
if (idCardImage.value == null) {
|
||||||
|
idCardError.value = 'Please upload an ID card image first';
|
||||||
|
isIdCardValid.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isVerifying.value = true;
|
||||||
|
final idCardType = isOfficer ? 'KTA' : 'KTP';
|
||||||
|
|
||||||
|
// Basic validation to check if the image is clear enough for OCR
|
||||||
|
bool isImageValid = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to process the ID card to check if it can be processed properly
|
||||||
|
final result = await _ocrService.processIdCard(
|
||||||
|
idCardImage.value!,
|
||||||
|
isOfficer,
|
||||||
|
);
|
||||||
|
// If we get here without an exception, the image is likely valid
|
||||||
|
isImageValid = result.isNotEmpty;
|
||||||
|
|
||||||
|
if (isImageValid) {
|
||||||
|
isIdCardValid.value = true;
|
||||||
|
idCardValidationMessage.value =
|
||||||
|
'$idCardType image looks valid. Please confirm this is your $idCardType.';
|
||||||
|
} else {
|
||||||
|
isIdCardValid.value = false;
|
||||||
|
idCardValidationMessage.value =
|
||||||
|
'Unable to verify your $idCardType clearly. Please ensure all text is visible and try again.';
|
||||||
|
}
|
||||||
|
} on SocketException catch (e) {
|
||||||
|
isIdCardValid.value = false;
|
||||||
|
if (e.message.contains('Connection timed out') ||
|
||||||
|
e.message.contains('Connection refused')) {
|
||||||
|
idCardValidationMessage.value =
|
||||||
|
'Unable to connect to verification service. Please check your internet connection and try again later.';
|
||||||
|
} else {
|
||||||
|
idCardValidationMessage.value =
|
||||||
|
'Network error occurred. Please try again later.';
|
||||||
|
}
|
||||||
|
} catch (processingError) {
|
||||||
|
isIdCardValid.value = false;
|
||||||
|
idCardValidationMessage.value =
|
||||||
|
'We\'re having trouble processing your $idCardType. Please try again with a clearer image.';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
isIdCardValid.value = false;
|
||||||
|
|
||||||
|
// Provide user-friendly error messages based on error type
|
||||||
|
if (e is SocketException) {
|
||||||
|
idCardValidationMessage.value =
|
||||||
|
'Connection problem detected. Please check your internet connection and try again.';
|
||||||
|
} else if (e.toString().contains('timeout')) {
|
||||||
|
idCardValidationMessage.value =
|
||||||
|
'The verification is taking too long. Please try again later when the connection is better.';
|
||||||
|
} else {
|
||||||
|
// Generic but still user-friendly error message
|
||||||
|
idCardValidationMessage.value =
|
||||||
|
'We encountered an issue during verification. Please try again later.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the actual error for debugging (wouldn't be shown to the user)
|
||||||
|
print('ID Card validation error: ${e.toString()}');
|
||||||
|
} finally {
|
||||||
|
isVerifying.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear ID Card Image
|
||||||
|
void clearIdCardImage() {
|
||||||
|
idCardImage.value = null;
|
||||||
|
idCardError.value = '';
|
||||||
|
isIdCardValid.value = false;
|
||||||
|
idCardValidationMessage.value = '';
|
||||||
|
hasConfirmedIdCard.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm ID Card Image
|
||||||
|
void confirmIdCardImage() {
|
||||||
|
if (isIdCardValid.value) {
|
||||||
|
hasConfirmedIdCard.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,407 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/image_verification_controller.dart';
|
||||||
|
import 'package:sigap/src/utils/validators/validation.dart';
|
||||||
|
|
||||||
|
class IdentityVerificationController extends GetxController {
|
||||||
|
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||||
|
final AzureOCRService _ocrService = AzureOCRService();
|
||||||
|
final bool isOfficer;
|
||||||
|
|
||||||
|
IdentityVerificationController({required this.isOfficer});
|
||||||
|
|
||||||
|
// Reference to image verification controller to access the validated images
|
||||||
|
late ImageVerificationController imageVerificationController;
|
||||||
|
|
||||||
|
// Controllers for viewer (non-officer)
|
||||||
|
final nikController = TextEditingController();
|
||||||
|
final bioController = TextEditingController();
|
||||||
|
final birthDateController = TextEditingController();
|
||||||
|
|
||||||
|
// New controllers for KTP fields
|
||||||
|
final fullNameController = TextEditingController();
|
||||||
|
final placeOfBirthController = TextEditingController();
|
||||||
|
final addressController = TextEditingController();
|
||||||
|
final RxString selectedGender = ''.obs;
|
||||||
|
|
||||||
|
// Error states
|
||||||
|
final RxString nikError = ''.obs;
|
||||||
|
final RxString bioError = ''.obs;
|
||||||
|
final RxString birthDateError = ''.obs;
|
||||||
|
|
||||||
|
// New error states for KTP fields
|
||||||
|
final RxString fullNameError = ''.obs;
|
||||||
|
final RxString placeOfBirthError = ''.obs;
|
||||||
|
final RxString genderError = ''.obs;
|
||||||
|
final RxString addressError = ''.obs;
|
||||||
|
|
||||||
|
// OCR verification states
|
||||||
|
final RxBool isVerifying = RxBool(false);
|
||||||
|
final RxBool isVerified = RxBool(false);
|
||||||
|
final RxString verificationMessage = RxString('');
|
||||||
|
|
||||||
|
// Face verification states
|
||||||
|
final RxBool isVerifyingFace = RxBool(false);
|
||||||
|
final RxBool isFaceVerified = RxBool(false);
|
||||||
|
final RxString faceVerificationMessage = RxString('');
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
|
||||||
|
// Get reference to the image verification controller
|
||||||
|
try {
|
||||||
|
imageVerificationController = Get.find<ImageVerificationController>();
|
||||||
|
} catch (e) {
|
||||||
|
// Controller not initialized yet, will retry later
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool validate() {
|
||||||
|
clearErrors();
|
||||||
|
|
||||||
|
if (formKey.currentState?.validate() ?? false) {
|
||||||
|
// Form validation passed, now check verification status
|
||||||
|
if (!isVerified.value) {
|
||||||
|
verificationMessage.value = 'Please complete ID card verification';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFaceVerified.value) {
|
||||||
|
faceVerificationMessage.value = 'Please complete face verification';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual validation as fallback
|
||||||
|
bool isValid = true;
|
||||||
|
|
||||||
|
if (!isOfficer) {
|
||||||
|
final nikValidation = TValidators.validateUserInput(
|
||||||
|
'NIK',
|
||||||
|
nikController.text,
|
||||||
|
16,
|
||||||
|
);
|
||||||
|
if (nikValidation != null) {
|
||||||
|
nikError.value = nikValidation;
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate full name
|
||||||
|
final fullNameValidation = TValidators.validateUserInput(
|
||||||
|
'Full Name',
|
||||||
|
fullNameController.text,
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
if (fullNameValidation != null) {
|
||||||
|
fullNameError.value = fullNameValidation;
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate place of birth
|
||||||
|
final placeOfBirthValidation = TValidators.validateUserInput(
|
||||||
|
'Place of Birth',
|
||||||
|
placeOfBirthController.text,
|
||||||
|
50,
|
||||||
|
);
|
||||||
|
if (placeOfBirthValidation != null) {
|
||||||
|
placeOfBirthError.value = placeOfBirthValidation;
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate gender
|
||||||
|
if (selectedGender.value.isEmpty) {
|
||||||
|
genderError.value = 'Gender is required';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate address
|
||||||
|
final addressValidation = TValidators.validateUserInput(
|
||||||
|
'Address',
|
||||||
|
addressController.text,
|
||||||
|
255,
|
||||||
|
);
|
||||||
|
if (addressValidation != null) {
|
||||||
|
addressError.value = addressValidation;
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bio can be optional, so we validate with required: false
|
||||||
|
final bioValidation = TValidators.validateUserInput(
|
||||||
|
'Bio',
|
||||||
|
bioController.text,
|
||||||
|
255,
|
||||||
|
required: false,
|
||||||
|
);
|
||||||
|
if (bioValidation != null) {
|
||||||
|
bioError.value = bioValidation;
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Birth date validation
|
||||||
|
final birthDateValidation = TValidators.validateUserInput(
|
||||||
|
'Birth Date',
|
||||||
|
birthDateController.text,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
if (birthDateValidation != null) {
|
||||||
|
birthDateError.value = birthDateValidation;
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid && isVerified.value && isFaceVerified.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearErrors() {
|
||||||
|
nikError.value = '';
|
||||||
|
bioError.value = '';
|
||||||
|
birthDateError.value = '';
|
||||||
|
fullNameError.value = '';
|
||||||
|
placeOfBirthError.value = '';
|
||||||
|
genderError.value = '';
|
||||||
|
addressError.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ID Card using OCR and compare with entered data
|
||||||
|
Future<void> verifyIdCardWithOCR() async {
|
||||||
|
// Make sure we have reference to the image controller
|
||||||
|
if (!Get.isRegistered<ImageVerificationController>()) {
|
||||||
|
verificationMessage.value = 'Error: Image verification data unavailable';
|
||||||
|
isVerified.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
imageVerificationController = Get.find<ImageVerificationController>();
|
||||||
|
} catch (e) {
|
||||||
|
verificationMessage.value = 'Error: Image verification data unavailable';
|
||||||
|
isVerified.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final idCardImage = imageVerificationController.idCardImage.value;
|
||||||
|
|
||||||
|
if (idCardImage == null) {
|
||||||
|
verificationMessage.value =
|
||||||
|
'ID card image missing. Please go back and upload it.';
|
||||||
|
isVerified.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imageVerificationController.isIdCardValid.value ||
|
||||||
|
!imageVerificationController.hasConfirmedIdCard.value) {
|
||||||
|
verificationMessage.value =
|
||||||
|
'ID card image not validated. Please go back and validate it first.';
|
||||||
|
isVerified.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isVerifying.value = true;
|
||||||
|
final idCardType = isOfficer ? 'KTA' : 'KTP';
|
||||||
|
|
||||||
|
// Call Azure OCR service with the appropriate ID type
|
||||||
|
final result = await _ocrService.processIdCard(idCardImage, isOfficer);
|
||||||
|
|
||||||
|
// Compare OCR results with user input
|
||||||
|
final bool isMatch =
|
||||||
|
isOfficer ? _verifyKtaResults(result) : _verifyKtpResults(result);
|
||||||
|
|
||||||
|
isVerified.value = isMatch;
|
||||||
|
verificationMessage.value =
|
||||||
|
isMatch
|
||||||
|
? '$idCardType verification successful! Your information matches with your $idCardType.'
|
||||||
|
: 'Verification failed. Please ensure your entered information matches your $idCardType.';
|
||||||
|
} catch (e) {
|
||||||
|
isVerified.value = false;
|
||||||
|
verificationMessage.value = 'OCR processing failed: ${e.toString()}';
|
||||||
|
} finally {
|
||||||
|
isVerifying.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify selfie with ID card
|
||||||
|
Future<void> verifyFaceMatch() async {
|
||||||
|
// Make sure we have reference to the image controller
|
||||||
|
if (!Get.isRegistered<ImageVerificationController>()) {
|
||||||
|
faceVerificationMessage.value =
|
||||||
|
'Error: Image verification data unavailable';
|
||||||
|
isFaceVerified.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
imageVerificationController = Get.find<ImageVerificationController>();
|
||||||
|
} catch (e) {
|
||||||
|
faceVerificationMessage.value =
|
||||||
|
'Error: Image verification data unavailable';
|
||||||
|
isFaceVerified.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final idCardImage = imageVerificationController.idCardImage.value;
|
||||||
|
final selfieImage = imageVerificationController.selfieImage.value;
|
||||||
|
|
||||||
|
if (idCardImage == null) {
|
||||||
|
faceVerificationMessage.value =
|
||||||
|
'ID card image missing. Please go back and upload it.';
|
||||||
|
isFaceVerified.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imageVerificationController.isIdCardValid.value ||
|
||||||
|
!imageVerificationController.hasConfirmedIdCard.value) {
|
||||||
|
faceVerificationMessage.value =
|
||||||
|
'ID card image not validated. Please go back and validate it first.';
|
||||||
|
isFaceVerified.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selfieImage == null) {
|
||||||
|
faceVerificationMessage.value =
|
||||||
|
'Selfie image missing. Please go back and take a selfie.';
|
||||||
|
isFaceVerified.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imageVerificationController.isSelfieValid.value ||
|
||||||
|
!imageVerificationController.hasConfirmedSelfie.value) {
|
||||||
|
faceVerificationMessage.value =
|
||||||
|
'Selfie not validated. Please go back and validate it first.';
|
||||||
|
isFaceVerified.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isVerifyingFace.value = true;
|
||||||
|
|
||||||
|
// Compare face in ID card with selfie face
|
||||||
|
final result = await _ocrService.verifyFace(idCardImage, selfieImage);
|
||||||
|
|
||||||
|
isFaceVerified.value = result['isMatch'] ?? false;
|
||||||
|
faceVerificationMessage.value = result['message'] ?? '';
|
||||||
|
} catch (e) {
|
||||||
|
isFaceVerified.value = false;
|
||||||
|
faceVerificationMessage.value =
|
||||||
|
'Face verification failed: ${e.toString()}';
|
||||||
|
} finally {
|
||||||
|
isVerifyingFace.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare OCR results with user input for KTP
|
||||||
|
bool _verifyKtpResults(Map<String, String> ocrResults) {
|
||||||
|
int matchCount = 0;
|
||||||
|
int totalFields = 0;
|
||||||
|
|
||||||
|
// Check NIK matches (exact match required for numbers)
|
||||||
|
if (ocrResults.containsKey('nik') && nikController.text.isNotEmpty) {
|
||||||
|
totalFields++;
|
||||||
|
// Clean up any spaces or special characters from OCR result
|
||||||
|
String ocrNik = ocrResults['nik']!.replaceAll(RegExp(r'[^0-9]'), '');
|
||||||
|
if (ocrNik == nikController.text) {
|
||||||
|
matchCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check birth date (flexible format matching)
|
||||||
|
if (ocrResults.containsKey('tanggal_lahir') &&
|
||||||
|
birthDateController.text.isNotEmpty) {
|
||||||
|
totalFields++;
|
||||||
|
// Convert both to a common format for comparison
|
||||||
|
String userDate = normalizeDateFormat(birthDateController.text);
|
||||||
|
String ocrDate = normalizeDateFormat(ocrResults['tanggal_lahir']!);
|
||||||
|
if (userDate == ocrDate) {
|
||||||
|
matchCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check full name
|
||||||
|
if (ocrResults.containsKey('nama') && fullNameController.text.isNotEmpty) {
|
||||||
|
totalFields++;
|
||||||
|
// Case-insensitive comparison for names
|
||||||
|
if (ocrResults['nama']!.toLowerCase().trim() ==
|
||||||
|
fullNameController.text.toLowerCase().trim()) {
|
||||||
|
matchCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check place of birth
|
||||||
|
if (ocrResults.containsKey('tempat_lahir') &&
|
||||||
|
placeOfBirthController.text.isNotEmpty) {
|
||||||
|
totalFields++;
|
||||||
|
// Case-insensitive comparison for place names
|
||||||
|
if (ocrResults['tempat_lahir']!.toLowerCase().trim().contains(
|
||||||
|
placeOfBirthController.text.toLowerCase().trim(),
|
||||||
|
)) {
|
||||||
|
matchCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check address (partial match is acceptable for address)
|
||||||
|
if (ocrResults.containsKey('alamat') && addressController.text.isNotEmpty) {
|
||||||
|
totalFields++;
|
||||||
|
// Check if user-entered address is contained within OCR address
|
||||||
|
if (ocrResults['alamat']!.toLowerCase().contains(
|
||||||
|
addressController.text.toLowerCase(),
|
||||||
|
)) {
|
||||||
|
matchCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require at least 60% of the fields to match
|
||||||
|
return totalFields > 0 && (matchCount / totalFields) >= 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare OCR results with user input for KTA (Officer ID)
|
||||||
|
bool _verifyKtaResults(Map<String, String> ocrResults) {
|
||||||
|
// Since we're dealing with officer info in a separate step,
|
||||||
|
// this will compare only birthdate and general info, which is minimal
|
||||||
|
|
||||||
|
int matchCount = 0;
|
||||||
|
int totalFields = 0;
|
||||||
|
|
||||||
|
// Check birth date if available
|
||||||
|
if (ocrResults.containsKey('tanggal_lahir') &&
|
||||||
|
birthDateController.text.isNotEmpty) {
|
||||||
|
totalFields++;
|
||||||
|
String userDate = normalizeDateFormat(birthDateController.text);
|
||||||
|
String ocrDate = normalizeDateFormat(ocrResults['tanggal_lahir']!);
|
||||||
|
if (userDate == ocrDate) {
|
||||||
|
matchCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simpler comparison for KTA at this step
|
||||||
|
return totalFields > 0 ? (matchCount / totalFields) >= 0.5 : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to normalize date formats for comparison
|
||||||
|
String normalizeDateFormat(String dateStr) {
|
||||||
|
// Remove non-numeric characters
|
||||||
|
String numericOnly = dateStr.replaceAll(RegExp(r'[^0-9]'), '');
|
||||||
|
|
||||||
|
// Try to extract year, month, day in a standardized format
|
||||||
|
if (numericOnly.length >= 8) {
|
||||||
|
// Assume YYYYMMDD format
|
||||||
|
return numericOnly.substring(0, 8);
|
||||||
|
} else {
|
||||||
|
return numericOnly;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onClose() {
|
||||||
|
nikController.dispose();
|
||||||
|
bioController.dispose();
|
||||||
|
birthDateController.dispose();
|
||||||
|
fullNameController.dispose();
|
||||||
|
placeOfBirthController.dispose();
|
||||||
|
addressController.dispose();
|
||||||
|
super.onClose();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,250 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
|
||||||
|
|
||||||
|
class ImageVerificationController extends GetxController {
|
||||||
|
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||||
|
final AzureOCRService _ocrService = AzureOCRService();
|
||||||
|
final bool isOfficer;
|
||||||
|
|
||||||
|
ImageVerificationController({required this.isOfficer});
|
||||||
|
|
||||||
|
// ID Card variables
|
||||||
|
final Rx<XFile?> idCardImage = Rx<XFile?>(null);
|
||||||
|
final RxString idCardError = RxString('');
|
||||||
|
final RxBool isVerifying = RxBool(false);
|
||||||
|
final RxBool isIdCardValid = RxBool(false);
|
||||||
|
final RxString idCardValidationMessage = RxString('');
|
||||||
|
|
||||||
|
// Loading states for image uploading
|
||||||
|
final RxBool isUploadingIdCard = RxBool(false);
|
||||||
|
final RxBool isUploadingSelfie = RxBool(false);
|
||||||
|
|
||||||
|
// Face verification variables
|
||||||
|
final Rx<XFile?> selfieImage = Rx<XFile?>(null);
|
||||||
|
final RxString selfieError = RxString('');
|
||||||
|
final RxBool isVerifyingFace = RxBool(false);
|
||||||
|
final RxBool isSelfieValid = RxBool(false);
|
||||||
|
final RxString selfieValidationMessage = RxString('');
|
||||||
|
final RxBool isLivenessCheckPassed = RxBool(false);
|
||||||
|
final RxBool isPerformingLivenessCheck = RxBool(false);
|
||||||
|
|
||||||
|
// Confirmation status
|
||||||
|
final RxBool hasConfirmedIdCard = RxBool(false);
|
||||||
|
final RxBool hasConfirmedSelfie = RxBool(false);
|
||||||
|
|
||||||
|
bool validate() {
|
||||||
|
clearErrors();
|
||||||
|
|
||||||
|
// For this step, we just need to ensure both images are uploaded and initially validated
|
||||||
|
bool isValid = true;
|
||||||
|
|
||||||
|
if (idCardImage.value == null) {
|
||||||
|
final idCardType = isOfficer ? 'KTA' : 'KTP';
|
||||||
|
idCardError.value = 'Please upload your $idCardType image';
|
||||||
|
isValid = false;
|
||||||
|
} else if (!isIdCardValid.value) {
|
||||||
|
idCardError.value = 'Your ID card image is not valid';
|
||||||
|
isValid = false;
|
||||||
|
} else if (!hasConfirmedIdCard.value) {
|
||||||
|
idCardError.value = 'Please confirm your ID card image';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selfieImage.value == null) {
|
||||||
|
selfieError.value = 'Please take a selfie for verification';
|
||||||
|
isValid = false;
|
||||||
|
} else if (!isSelfieValid.value) {
|
||||||
|
selfieError.value = 'Your selfie image is not valid';
|
||||||
|
isValid = false;
|
||||||
|
} else if (!hasConfirmedSelfie.value) {
|
||||||
|
selfieError.value = 'Please confirm your selfie image';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearErrors() {
|
||||||
|
idCardError.value = '';
|
||||||
|
selfieError.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick ID Card Image
|
||||||
|
Future<void> pickIdCardImage(ImageSource source) async {
|
||||||
|
try {
|
||||||
|
isUploadingIdCard.value = true;
|
||||||
|
hasConfirmedIdCard.value =
|
||||||
|
false; // Reset confirmation whenever image changes
|
||||||
|
|
||||||
|
final ImagePicker picker = ImagePicker();
|
||||||
|
final XFile? image = await picker.pickImage(
|
||||||
|
source: source,
|
||||||
|
imageQuality: 80,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (image != null) {
|
||||||
|
// Add artificial delay to show loading state
|
||||||
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
|
idCardImage.value = image;
|
||||||
|
idCardError.value = '';
|
||||||
|
|
||||||
|
// Initial validation of the ID card (simple check)
|
||||||
|
await validateIdCardImage();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
idCardError.value = 'Failed to pick image: $e';
|
||||||
|
isIdCardValid.value = false;
|
||||||
|
} finally {
|
||||||
|
isUploadingIdCard.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take or pick selfie image
|
||||||
|
Future<void> pickSelfieImage(ImageSource source) async {
|
||||||
|
try {
|
||||||
|
isUploadingSelfie.value = true;
|
||||||
|
hasConfirmedSelfie.value =
|
||||||
|
false; // Reset confirmation whenever image changes
|
||||||
|
|
||||||
|
final ImagePicker picker = ImagePicker();
|
||||||
|
final XFile? image = await picker.pickImage(
|
||||||
|
source: source,
|
||||||
|
preferredCameraDevice: CameraDevice.front,
|
||||||
|
imageQuality: 80,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (image != null) {
|
||||||
|
// Add artificial delay to show loading state
|
||||||
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
|
selfieImage.value = image;
|
||||||
|
selfieError.value = '';
|
||||||
|
|
||||||
|
// Initial validation of the selfie (face detection)
|
||||||
|
await validateSelfieImage();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
selfieError.value = 'Failed to capture selfie: $e';
|
||||||
|
isSelfieValid.value = false;
|
||||||
|
} finally {
|
||||||
|
isUploadingSelfie.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial validation of ID card image
|
||||||
|
Future<void> validateIdCardImage() async {
|
||||||
|
if (idCardImage.value == null) {
|
||||||
|
idCardError.value = 'Please upload an ID card image first';
|
||||||
|
isIdCardValid.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isVerifying.value = true;
|
||||||
|
final idCardType = isOfficer ? 'KTA' : 'KTP';
|
||||||
|
|
||||||
|
// Basic validation to check if the image is clear enough for OCR
|
||||||
|
// Since the _detectFaces method is private, we'll use a public method to detect faces
|
||||||
|
// or simulate it for validation purposes
|
||||||
|
bool isImageValid = true; // Default to true for now
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to use processIdCard method to check if the image can be processed properly
|
||||||
|
final result = await _ocrService.processIdCard(
|
||||||
|
idCardImage.value!,
|
||||||
|
isOfficer,
|
||||||
|
);
|
||||||
|
// If we get here without an exception, the image is likely valid
|
||||||
|
isImageValid = result.isNotEmpty;
|
||||||
|
} catch (processingError) {
|
||||||
|
// If processing fails, the image might not be valid
|
||||||
|
isImageValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isImageValid) {
|
||||||
|
isIdCardValid.value = true;
|
||||||
|
idCardValidationMessage.value =
|
||||||
|
'$idCardType image looks valid. Please confirm this is your $idCardType.';
|
||||||
|
} else {
|
||||||
|
isIdCardValid.value = false;
|
||||||
|
idCardValidationMessage.value =
|
||||||
|
'The image doesn\'t appear to be a valid $idCardType. Please upload a clearer image.';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
isIdCardValid.value = false;
|
||||||
|
idCardValidationMessage.value = 'Validation failed: ${e.toString()}';
|
||||||
|
} finally {
|
||||||
|
isVerifying.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial validation of selfie image
|
||||||
|
Future<void> validateSelfieImage() async {
|
||||||
|
if (selfieImage.value == null) {
|
||||||
|
selfieError.value = 'Please take a selfie first';
|
||||||
|
isSelfieValid.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isVerifyingFace.value = true;
|
||||||
|
|
||||||
|
// Instead of directly calling _detectFaces, we'll use the liveness check
|
||||||
|
// which indirectly checks for faces
|
||||||
|
final livenessResult = await _ocrService.performLivenessCheck(
|
||||||
|
selfieImage.value!,
|
||||||
|
);
|
||||||
|
isLivenessCheckPassed.value = livenessResult['isLive'] ?? false;
|
||||||
|
|
||||||
|
if (livenessResult['isLive'] == true) {
|
||||||
|
isSelfieValid.value = true;
|
||||||
|
selfieValidationMessage.value =
|
||||||
|
'Face detected. Please confirm this is you.';
|
||||||
|
} else {
|
||||||
|
isSelfieValid.value = false;
|
||||||
|
selfieValidationMessage.value =
|
||||||
|
livenessResult['message'] ??
|
||||||
|
'No face detected or liveness check failed. Please take a clearer selfie.';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
isSelfieValid.value = false;
|
||||||
|
selfieValidationMessage.value = 'Validation failed: ${e.toString()}';
|
||||||
|
} finally {
|
||||||
|
isVerifyingFace.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear ID Card Image
|
||||||
|
void clearIdCardImage() {
|
||||||
|
idCardImage.value = null;
|
||||||
|
idCardError.value = '';
|
||||||
|
isIdCardValid.value = false;
|
||||||
|
idCardValidationMessage.value = '';
|
||||||
|
hasConfirmedIdCard.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear Selfie Image
|
||||||
|
void clearSelfieImage() {
|
||||||
|
selfieImage.value = null;
|
||||||
|
selfieError.value = '';
|
||||||
|
isSelfieValid.value = false;
|
||||||
|
selfieValidationMessage.value = '';
|
||||||
|
isLivenessCheckPassed.value = false;
|
||||||
|
hasConfirmedSelfie.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm ID Card Image
|
||||||
|
void confirmIdCardImage() {
|
||||||
|
if (isIdCardValid.value) {
|
||||||
|
hasConfirmedIdCard.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm Selfie Image
|
||||||
|
void confirmSelfieImage() {
|
||||||
|
if (isSelfieValid.value && isLivenessCheckPassed.value) {
|
||||||
|
hasConfirmedSelfie.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:sigap/src/utils/validators/validation.dart';
|
||||||
|
|
||||||
|
class OfficerInfoController extends GetxController {
|
||||||
|
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
// Controllers
|
||||||
|
final nrpController = TextEditingController();
|
||||||
|
final rankController = TextEditingController();
|
||||||
|
|
||||||
|
// Error states
|
||||||
|
final RxString nrpError = ''.obs;
|
||||||
|
final RxString rankError = ''.obs;
|
||||||
|
|
||||||
|
bool validate() {
|
||||||
|
clearErrors();
|
||||||
|
|
||||||
|
if (formKey.currentState?.validate() ?? false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual validation as fallback
|
||||||
|
bool isValid = true;
|
||||||
|
|
||||||
|
final nrpValidation = TValidators.validateUserInput(
|
||||||
|
'NRP',
|
||||||
|
nrpController.text,
|
||||||
|
50,
|
||||||
|
);
|
||||||
|
if (nrpValidation != null) {
|
||||||
|
nrpError.value = nrpValidation;
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final rankValidation = TValidators.validateUserInput(
|
||||||
|
'Rank',
|
||||||
|
rankController.text,
|
||||||
|
50,
|
||||||
|
);
|
||||||
|
if (rankValidation != null) {
|
||||||
|
rankError.value = rankValidation;
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearErrors() {
|
||||||
|
nrpError.value = '';
|
||||||
|
rankError.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onClose() {
|
||||||
|
nrpController.dispose();
|
||||||
|
rankController.dispose();
|
||||||
|
super.onClose();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,135 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:sigap/src/utils/validators/validation.dart';
|
||||||
|
|
||||||
|
class PersonalInfoController extends GetxController {
|
||||||
|
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
// Controllers
|
||||||
|
final firstNameController = TextEditingController();
|
||||||
|
final lastNameController = TextEditingController();
|
||||||
|
final nameController = TextEditingController(); // For combined name
|
||||||
|
final phoneController = TextEditingController();
|
||||||
|
final bioController = TextEditingController();
|
||||||
|
final addressController = TextEditingController();
|
||||||
|
|
||||||
|
// Error states
|
||||||
|
final RxString firstNameError = ''.obs;
|
||||||
|
final RxString lastNameError = ''.obs;
|
||||||
|
final RxString nameError = ''.obs;
|
||||||
|
final RxString phoneError = ''.obs;
|
||||||
|
final RxString bioError = ''.obs;
|
||||||
|
final RxString addressError = ''.obs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
// Add listeners to first name and last name controllers to update the combined name
|
||||||
|
firstNameController.addListener(_updateCombinedName);
|
||||||
|
lastNameController.addListener(_updateCombinedName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the combined name when either first or last name changes
|
||||||
|
void _updateCombinedName() {
|
||||||
|
final firstName = firstNameController.text.trim();
|
||||||
|
final lastName = lastNameController.text.trim();
|
||||||
|
|
||||||
|
if (firstName.isEmpty && lastName.isEmpty) {
|
||||||
|
nameController.text = '';
|
||||||
|
} else if (lastName.isEmpty) {
|
||||||
|
nameController.text = firstName;
|
||||||
|
} else if (firstName.isEmpty) {
|
||||||
|
nameController.text = lastName;
|
||||||
|
} else {
|
||||||
|
nameController.text = '$firstName $lastName';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool validate() {
|
||||||
|
clearErrors();
|
||||||
|
|
||||||
|
if (formKey.currentState?.validate() ?? false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual validation as fallback
|
||||||
|
bool isValid = true;
|
||||||
|
|
||||||
|
final firstNameValidation = TValidators.validateUserInput(
|
||||||
|
'First name',
|
||||||
|
firstNameController.text,
|
||||||
|
50,
|
||||||
|
);
|
||||||
|
if (firstNameValidation != null) {
|
||||||
|
firstNameError.value = firstNameValidation;
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final lastNameValidation = TValidators.validateUserInput(
|
||||||
|
'Last name',
|
||||||
|
lastNameController.text,
|
||||||
|
50,
|
||||||
|
required: false,
|
||||||
|
);
|
||||||
|
if (lastNameValidation != null) {
|
||||||
|
lastNameError.value = lastNameValidation;
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final phoneValidation = TValidators.validatePhoneNumber(
|
||||||
|
phoneController.text,
|
||||||
|
);
|
||||||
|
if (phoneValidation != null) {
|
||||||
|
phoneError.value = phoneValidation;
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bio can be optional, so we validate with required: false
|
||||||
|
final bioValidation = TValidators.validateUserInput(
|
||||||
|
'Bio',
|
||||||
|
bioController.text,
|
||||||
|
255,
|
||||||
|
required: false,
|
||||||
|
);
|
||||||
|
if (bioValidation != null) {
|
||||||
|
bioError.value = bioValidation;
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final addressValidation = TValidators.validateUserInput(
|
||||||
|
'Address',
|
||||||
|
addressController.text,
|
||||||
|
255,
|
||||||
|
);
|
||||||
|
if (addressValidation != null) {
|
||||||
|
addressError.value = addressValidation;
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearErrors() {
|
||||||
|
firstNameError.value = '';
|
||||||
|
lastNameError.value = '';
|
||||||
|
nameError.value = '';
|
||||||
|
phoneError.value = '';
|
||||||
|
bioError.value = '';
|
||||||
|
addressError.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onClose() {
|
||||||
|
firstNameController.removeListener(_updateCombinedName);
|
||||||
|
lastNameController.removeListener(_updateCombinedName);
|
||||||
|
|
||||||
|
firstNameController.dispose();
|
||||||
|
lastNameController.dispose();
|
||||||
|
nameController.dispose();
|
||||||
|
phoneController.dispose();
|
||||||
|
bioController.dispose();
|
||||||
|
addressController.dispose();
|
||||||
|
|
||||||
|
super.onClose();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,139 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
|
||||||
|
|
||||||
|
class SelfieVerificationController extends GetxController {
|
||||||
|
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||||
|
final AzureOCRService _ocrService = AzureOCRService();
|
||||||
|
|
||||||
|
// Face verification variables
|
||||||
|
final Rx<XFile?> selfieImage = Rx<XFile?>(null);
|
||||||
|
final RxString selfieError = RxString('');
|
||||||
|
final RxBool isVerifyingFace = RxBool(false);
|
||||||
|
final RxBool isSelfieValid = RxBool(false);
|
||||||
|
final RxString selfieValidationMessage = RxString('');
|
||||||
|
final RxBool isLivenessCheckPassed = RxBool(false);
|
||||||
|
final RxBool isPerformingLivenessCheck = RxBool(false);
|
||||||
|
|
||||||
|
// Loading states for image uploading
|
||||||
|
final RxBool isUploadingSelfie = RxBool(false);
|
||||||
|
|
||||||
|
// Confirmation status
|
||||||
|
final RxBool hasConfirmedSelfie = RxBool(false);
|
||||||
|
|
||||||
|
bool validate() {
|
||||||
|
clearErrors();
|
||||||
|
|
||||||
|
// For this step, we just need to ensure selfie is uploaded and validated
|
||||||
|
bool isValid = true;
|
||||||
|
|
||||||
|
if (selfieImage.value == null) {
|
||||||
|
selfieError.value = 'Please take a selfie for verification';
|
||||||
|
isValid = false;
|
||||||
|
} else if (!isSelfieValid.value) {
|
||||||
|
selfieError.value = 'Your selfie image is not valid';
|
||||||
|
isValid = false;
|
||||||
|
} else if (!hasConfirmedSelfie.value) {
|
||||||
|
selfieError.value = 'Please confirm your selfie image';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearErrors() {
|
||||||
|
selfieError.value = '';
|
||||||
|
selfieValidationMessage.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearSelfieValidationMessage() {
|
||||||
|
selfieValidationMessage.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take or pick selfie image
|
||||||
|
Future<void> pickSelfieImage(ImageSource source) async {
|
||||||
|
try {
|
||||||
|
isUploadingSelfie.value = true;
|
||||||
|
hasConfirmedSelfie.value =
|
||||||
|
false; // Reset confirmation whenever image changes
|
||||||
|
|
||||||
|
final ImagePicker picker = ImagePicker();
|
||||||
|
final XFile? image = await picker.pickImage(
|
||||||
|
source: source,
|
||||||
|
preferredCameraDevice: CameraDevice.front,
|
||||||
|
imageQuality: 80,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (image != null) {
|
||||||
|
// Add artificial delay to show loading state
|
||||||
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
|
selfieImage.value = image;
|
||||||
|
selfieError.value = '';
|
||||||
|
|
||||||
|
// Initial validation of the selfie (face detection)
|
||||||
|
await validateSelfieImage();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
selfieError.value = 'Failed to capture selfie: $e';
|
||||||
|
isSelfieValid.value = false;
|
||||||
|
} finally {
|
||||||
|
isUploadingSelfie.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial validation of selfie image
|
||||||
|
Future<void> validateSelfieImage() async {
|
||||||
|
// Clear previous validation messages
|
||||||
|
clearErrors();
|
||||||
|
|
||||||
|
if (selfieImage.value == null) {
|
||||||
|
selfieError.value = 'Please take a selfie first';
|
||||||
|
isSelfieValid.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isVerifyingFace.value = true;
|
||||||
|
|
||||||
|
// Use liveness check to validate if the selfie has a valid face
|
||||||
|
final livenessResult = await _ocrService.performLivenessCheck(
|
||||||
|
selfieImage.value!,
|
||||||
|
);
|
||||||
|
isLivenessCheckPassed.value = livenessResult['isLive'] ?? false;
|
||||||
|
|
||||||
|
if (livenessResult['isLive'] == true) {
|
||||||
|
isSelfieValid.value = true;
|
||||||
|
selfieValidationMessage.value =
|
||||||
|
'Face detected. Please confirm this is you.';
|
||||||
|
} else {
|
||||||
|
isSelfieValid.value = false;
|
||||||
|
selfieValidationMessage.value =
|
||||||
|
livenessResult['message'] ??
|
||||||
|
'No face detected or liveness check failed. Please take a clearer selfie.';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
isSelfieValid.value = false;
|
||||||
|
selfieValidationMessage.value = 'Validation failed: ${e.toString()}';
|
||||||
|
} finally {
|
||||||
|
isVerifyingFace.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear Selfie Image
|
||||||
|
void clearSelfieImage() {
|
||||||
|
selfieImage.value = null;
|
||||||
|
selfieError.value = '';
|
||||||
|
isSelfieValid.value = false;
|
||||||
|
selfieValidationMessage.value = '';
|
||||||
|
isLivenessCheckPassed.value = false;
|
||||||
|
hasConfirmedSelfie.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm Selfie Image
|
||||||
|
void confirmSelfieImage() {
|
||||||
|
if (isSelfieValid.value) {
|
||||||
|
hasConfirmedSelfie.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,59 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:sigap/src/features/daily-ops/data/models/index.dart';
|
||||||
|
import 'package:sigap/src/utils/validators/validation.dart';
|
||||||
|
|
||||||
|
class UnitInfoController extends GetxController {
|
||||||
|
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
// Controllers
|
||||||
|
final positionController = TextEditingController();
|
||||||
|
final unitIdController = TextEditingController();
|
||||||
|
|
||||||
|
// Available units for officer role
|
||||||
|
final RxList<UnitModel> availableUnits = <UnitModel>[].obs;
|
||||||
|
|
||||||
|
// Error states
|
||||||
|
final RxString positionError = ''.obs;
|
||||||
|
final RxString unitIdError = ''.obs;
|
||||||
|
|
||||||
|
bool validate() {
|
||||||
|
clearErrors();
|
||||||
|
|
||||||
|
if (formKey.currentState?.validate() ?? false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual validation as fallback
|
||||||
|
bool isValid = true;
|
||||||
|
|
||||||
|
final positionValidation = TValidators.validateUserInput(
|
||||||
|
'Position',
|
||||||
|
positionController.text,
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
if (positionValidation != null) {
|
||||||
|
positionError.value = positionValidation;
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unitIdController.text.isEmpty) {
|
||||||
|
unitIdError.value = 'Please select a unit';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearErrors() {
|
||||||
|
positionError.value = '';
|
||||||
|
unitIdError.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onClose() {
|
||||||
|
positionController.dispose();
|
||||||
|
unitIdController.dispose();
|
||||||
|
super.onClose();
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,125 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:sigap/src/features/auth/data/dummy/indonesian_cities.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
|
class CitySelectionPage extends StatefulWidget {
|
||||||
|
const CitySelectionPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CitySelectionPage> createState() => _CitySelectionPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CitySelectionPageState extends State<CitySelectionPage> {
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
final RxList<String> _filteredCities = <String>[].obs;
|
||||||
|
final List<String> _allCities = indonesianCities;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_filteredCities.value = _allCities;
|
||||||
|
|
||||||
|
_searchController.addListener(() {
|
||||||
|
_filterCities(_searchController.text);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _filterCities(String query) {
|
||||||
|
if (query.isEmpty) {
|
||||||
|
_filteredCities.value = _allCities;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final lowercaseQuery = query.toLowerCase();
|
||||||
|
_filteredCities.value =
|
||||||
|
_allCities
|
||||||
|
.where((city) => city.toLowerCase().contains(lowercaseQuery))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Select Place of Birth'),
|
||||||
|
backgroundColor: TColors.primary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(TSizes.defaultSpace),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search city or regency',
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
vertical: TSizes.sm,
|
||||||
|
horizontal: TSizes.md,
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.withOpacity(0.1),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.search,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Obx(
|
||||||
|
() =>
|
||||||
|
_filteredCities.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.location_city,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.grey.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'No cities found',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey.withOpacity(0.8),
|
||||||
|
fontSize: TSizes.fontSizeMd,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: _filteredCities.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final city = _filteredCities[index];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(city),
|
||||||
|
onTap: () {
|
||||||
|
Get.back(result: city);
|
||||||
|
},
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: TSizes.defaultSpace,
|
||||||
|
vertical: TSizes.xs,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,546 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
|
class IdCardVerificationStep extends StatelessWidget {
|
||||||
|
const IdCardVerificationStep({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final controller = Get.find<IdCardVerificationController>();
|
||||||
|
final mainController = Get.find<FormRegistrationController>();
|
||||||
|
final isOfficer = mainController.selectedRole.value?.isOfficer ?? false;
|
||||||
|
final String idCardType = isOfficer ? 'KTA' : 'KTP';
|
||||||
|
|
||||||
|
return Form(
|
||||||
|
key: controller.formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$idCardType Verification',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeLg,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: TColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'Upload a clear image of your $idCardType',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.headlineSmall?.copyWith(color: TColors.textSecondary),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.xs),
|
||||||
|
Text(
|
||||||
|
'Make sure all text and your photo are clearly visible',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelLarge?.copyWith(color: TColors.textSecondary),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
|
||||||
|
// ID Card Upload Widget
|
||||||
|
_buildIdCardUploader(controller, isOfficer),
|
||||||
|
|
||||||
|
// Verification Status for ID Card
|
||||||
|
// Obx(
|
||||||
|
// () =>
|
||||||
|
// controller.isVerifying.value &&
|
||||||
|
// !controller.isUploadingIdCard.value
|
||||||
|
// ? const Padding(
|
||||||
|
// padding: EdgeInsets.symmetric(
|
||||||
|
// vertical: TSizes.spaceBtwItems,
|
||||||
|
// ),
|
||||||
|
// child: Center(
|
||||||
|
// child: Column(
|
||||||
|
// children: [
|
||||||
|
// CircularProgressIndicator(),
|
||||||
|
// SizedBox(height: TSizes.sm),
|
||||||
|
// Text('Validating your ID card...'),
|
||||||
|
// ],
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// )
|
||||||
|
// : const SizedBox.shrink(),
|
||||||
|
// ),
|
||||||
|
|
||||||
|
// Verification Message for ID Card
|
||||||
|
Obx(
|
||||||
|
() =>
|
||||||
|
controller.idCardValidationMessage.value.isNotEmpty
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: TSizes.spaceBtwItems,
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(TSizes.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
controller.isIdCardValid.value
|
||||||
|
? Colors.green.withOpacity(0.1)
|
||||||
|
: Colors.red.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.borderRadiusSm,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Icon(
|
||||||
|
controller.isIdCardValid.value
|
||||||
|
? Icons.check_circle
|
||||||
|
: Icons.error,
|
||||||
|
color:
|
||||||
|
controller.isIdCardValid.value
|
||||||
|
? Colors.green
|
||||||
|
: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: TSizes.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
controller.idCardValidationMessage.value,
|
||||||
|
style: TextStyle(
|
||||||
|
color:
|
||||||
|
controller.isIdCardValid.value
|
||||||
|
? Colors.green
|
||||||
|
: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (controller.isIdCardValid.value &&
|
||||||
|
!controller.hasConfirmedIdCard.value) ...[
|
||||||
|
const SizedBox(height: TSizes.md),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed:
|
||||||
|
() => controller.confirmIdCardImage(),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
child: const Text('Confirm Image'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: TSizes.sm),
|
||||||
|
Expanded(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed:
|
||||||
|
() => controller.clearIdCardImage(),
|
||||||
|
child: const Text('Try Another Image'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (controller.hasConfirmedIdCard.value)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: TSizes.sm),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: Colors.green,
|
||||||
|
size: TSizes.iconSm,
|
||||||
|
),
|
||||||
|
SizedBox(width: TSizes.xs),
|
||||||
|
Text(
|
||||||
|
'Image confirmed',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.green,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Error Messages
|
||||||
|
Obx(
|
||||||
|
() =>
|
||||||
|
controller.idCardError.value.isNotEmpty
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.only(top: TSizes.sm),
|
||||||
|
child: Text(
|
||||||
|
controller.idCardError.value,
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildIdCardUploader(
|
||||||
|
IdCardVerificationController controller,
|
||||||
|
bool isOfficer,
|
||||||
|
) {
|
||||||
|
final String idCardType = isOfficer ? 'KTA' : 'KTP';
|
||||||
|
|
||||||
|
return Obx(() {
|
||||||
|
// Background color based on error state or confirmation
|
||||||
|
final backgroundColor =
|
||||||
|
controller.idCardError.value.isNotEmpty
|
||||||
|
? TColors.error.withOpacity(0.1)
|
||||||
|
: controller.hasConfirmedIdCard.value
|
||||||
|
? Colors.green.withOpacity(0.1)
|
||||||
|
: Colors.grey.withOpacity(0.1);
|
||||||
|
|
||||||
|
// Determine border color based on error state or confirmation
|
||||||
|
final borderColor =
|
||||||
|
controller.idCardError.value.isNotEmpty
|
||||||
|
? TColors.error
|
||||||
|
: controller.hasConfirmedIdCard.value
|
||||||
|
? Colors.green
|
||||||
|
: Colors.grey.withOpacity(0.5);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (controller.idCardImage.value == null)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _showImageSourceDialog(controller, isOfficer),
|
||||||
|
child: Container(
|
||||||
|
height: 180,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor, // Using the dynamic background color
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
||||||
|
border: Border.all(
|
||||||
|
color: borderColor,
|
||||||
|
width:
|
||||||
|
controller.idCardError.value.isNotEmpty
|
||||||
|
? 2
|
||||||
|
: 1, // Thicker border for error state
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
controller.isUploadingIdCard.value
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'Uploading...',
|
||||||
|
style: TextStyle(color: TColors.textSecondary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
controller.idCardError.value.isNotEmpty
|
||||||
|
? Icons.error_outline
|
||||||
|
: Icons.add_a_photo,
|
||||||
|
size: TSizes.iconLg,
|
||||||
|
color:
|
||||||
|
controller.idCardError.value.isNotEmpty
|
||||||
|
? TColors.error
|
||||||
|
: Colors.grey,
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
controller.idCardError.value.isNotEmpty
|
||||||
|
? 'Please upload your $idCardType image first'
|
||||||
|
: 'Upload $idCardType Image',
|
||||||
|
style: TextStyle(
|
||||||
|
color:
|
||||||
|
controller.idCardError.value.isNotEmpty
|
||||||
|
? TColors.error
|
||||||
|
: TColors.textSecondary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.xs),
|
||||||
|
Text(
|
||||||
|
'Tap to select an image',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
color:
|
||||||
|
controller.idCardError.value.isNotEmpty
|
||||||
|
? TColors.error.withOpacity(0.8)
|
||||||
|
: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Stack(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.borderRadiusMd,
|
||||||
|
),
|
||||||
|
border: Border.all(color: borderColor, width: 2),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.borderRadiusMd -
|
||||||
|
2, // Adjust for border width
|
||||||
|
),
|
||||||
|
child: Image.file(
|
||||||
|
File(controller.idCardImage.value!.path),
|
||||||
|
height: 200,
|
||||||
|
width: double.infinity,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Error overlay for uploaded image
|
||||||
|
if (controller.idCardError.value.isNotEmpty &&
|
||||||
|
!controller.isUploadingIdCard.value &&
|
||||||
|
!controller.isVerifying.value)
|
||||||
|
Container(
|
||||||
|
height: 200,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.borderRadiusMd -
|
||||||
|
2, // Adjust for border width
|
||||||
|
),
|
||||||
|
color: TColors.error.withOpacity(0.2),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
color: TColors.error,
|
||||||
|
size: TSizes.iconLg,
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'Invalid Image',
|
||||||
|
style: TextStyle(
|
||||||
|
color: TColors.error,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.xs),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: TSizes.md,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Please upload a clearer image',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: TColors.error,
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Loading overlay
|
||||||
|
if (controller.isUploadingIdCard.value ||
|
||||||
|
controller.isVerifying.value)
|
||||||
|
Container(
|
||||||
|
height: 200,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.borderRadiusMd -
|
||||||
|
2, // Adjust for border width
|
||||||
|
),
|
||||||
|
color: Colors.black.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'Processing...',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!controller.hasConfirmedIdCard.value &&
|
||||||
|
!controller.isUploadingIdCard.value &&
|
||||||
|
!controller.isVerifying.value)
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => controller.clearIdCardImage(),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(TSizes.xs),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
controller.idCardError.value.isNotEmpty
|
||||||
|
? TColors.error
|
||||||
|
: Colors.black54,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.close,
|
||||||
|
color: Colors.white,
|
||||||
|
size: TSizes.iconSm,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
if (!controller.isIdCardValid.value &&
|
||||||
|
!controller.isVerifying.value &&
|
||||||
|
!controller.isUploadingIdCard.value)
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () => controller.validateIdCardImage(),
|
||||||
|
icon: const Icon(Icons.check_circle),
|
||||||
|
label: Text('Check $idCardType Validity'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: TColors.primary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
minimumSize: const Size(double.infinity, 50),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showImageSourceDialog(
|
||||||
|
IdCardVerificationController controller,
|
||||||
|
bool isOfficer,
|
||||||
|
) {
|
||||||
|
final String idCardType = isOfficer ? 'KTA' : 'KTP';
|
||||||
|
final String title = 'Select $idCardType Image Source';
|
||||||
|
final String message =
|
||||||
|
'Please ensure your ID card is clear, well-lit, and all text is readable';
|
||||||
|
|
||||||
|
Get.dialog(
|
||||||
|
Dialog(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(TSizes.defaultSpace),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeLg,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.md),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
color: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_buildImageSourceOption(
|
||||||
|
icon: Icons.camera_alt,
|
||||||
|
label: 'Camera',
|
||||||
|
onTap: () {
|
||||||
|
controller.pickIdCardImage(ImageSource.camera);
|
||||||
|
Get.back();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_buildImageSourceOption(
|
||||||
|
icon: Icons.image,
|
||||||
|
label: 'Gallery',
|
||||||
|
onTap: () {
|
||||||
|
controller.pickIdCardImage(ImageSource.gallery);
|
||||||
|
Get.back();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildImageSourceOption({
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(TSizes.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: TColors.primary.withOpacity(0.1),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: TColors.primary, size: TSizes.iconLg),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(label),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,587 @@
|
||||||
|
import 'package:calendar_date_picker2/calendar_date_picker2.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/image_verification_controller.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/city_selection.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
import 'package:sigap/src/utils/validators/validation.dart';
|
||||||
|
|
||||||
|
class IdentityVerificationStep extends StatelessWidget {
|
||||||
|
const IdentityVerificationStep({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final controller = Get.find<IdentityVerificationController>();
|
||||||
|
final ImageVerificationController imageController;
|
||||||
|
|
||||||
|
try {
|
||||||
|
imageController = Get.find<ImageVerificationController>();
|
||||||
|
} catch (e) {
|
||||||
|
// Handle the case when ImageVerificationController is not registered yet
|
||||||
|
// Use a local variable or default behavior
|
||||||
|
}
|
||||||
|
|
||||||
|
final mainController = Get.find<FormRegistrationController>();
|
||||||
|
final isOfficer = mainController.selectedRole.value?.isOfficer ?? false;
|
||||||
|
final String idCardType = isOfficer ? 'KTA' : 'KTP';
|
||||||
|
|
||||||
|
return Form(
|
||||||
|
key: controller.formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Additional Information',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeLg,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: TColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'Please provide additional personal details',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
color: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
|
||||||
|
// Different fields based on role
|
||||||
|
if (!isOfficer) ...[
|
||||||
|
// NIK field for viewers
|
||||||
|
Obx(
|
||||||
|
() => CustomTextField(
|
||||||
|
label: 'NIK (Identity Number)',
|
||||||
|
controller: controller.nikController,
|
||||||
|
validator:
|
||||||
|
(value) => TValidators.validateUserInput('NIK', value, 16),
|
||||||
|
errorText: controller.nikError.value,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
hintText: 'e.g., 1234567890123456',
|
||||||
|
onChanged: (value) {
|
||||||
|
controller.nikController.text = value;
|
||||||
|
controller.nikError.value = '';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Full Name field
|
||||||
|
Obx(
|
||||||
|
() => CustomTextField(
|
||||||
|
label: 'Full Name',
|
||||||
|
controller: controller.fullNameController,
|
||||||
|
validator:
|
||||||
|
(value) =>
|
||||||
|
TValidators.validateUserInput('Full Name', value, 100),
|
||||||
|
errorText: controller.fullNameError.value,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
hintText: 'Enter your full name as on KTP',
|
||||||
|
onChanged: (value) {
|
||||||
|
controller.fullNameController.text = value;
|
||||||
|
controller.fullNameError.value = '';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Place of Birth field with city selection
|
||||||
|
Obx(() => _buildPlaceOfBirthField(context, controller)),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Birth Date field with calendar picker
|
||||||
|
Obx(() => _buildBirthDatePicker(context, controller)),
|
||||||
|
|
||||||
|
// Gender selection
|
||||||
|
Obx(() => _buildGenderSelection(context, controller)),
|
||||||
|
|
||||||
|
// Address field
|
||||||
|
Obx(
|
||||||
|
() => CustomTextField(
|
||||||
|
label: 'Address',
|
||||||
|
controller: controller.addressController,
|
||||||
|
validator:
|
||||||
|
(value) =>
|
||||||
|
TValidators.validateUserInput('Address', value, 255),
|
||||||
|
errorText: controller.addressError.value,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
hintText: 'Enter your address as on KTP',
|
||||||
|
maxLines: 3,
|
||||||
|
onChanged: (value) {
|
||||||
|
controller.addressController.text = value;
|
||||||
|
controller.addressError.value = '';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Verification Status
|
||||||
|
Obx(
|
||||||
|
() =>
|
||||||
|
controller.isVerifying.value
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
vertical: TSizes.spaceBtwItems,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
SizedBox(height: TSizes.sm),
|
||||||
|
Text('Processing your personal information...'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Verification Message
|
||||||
|
Obx(
|
||||||
|
() =>
|
||||||
|
controller.verificationMessage.value.isNotEmpty
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: TSizes.spaceBtwItems,
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(TSizes.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
controller.isVerified.value
|
||||||
|
? Colors.green.withOpacity(0.1)
|
||||||
|
: Colors.red.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.borderRadiusSm,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Icon(
|
||||||
|
controller.isVerified.value
|
||||||
|
? Icons.check_circle
|
||||||
|
: Icons.error,
|
||||||
|
color:
|
||||||
|
controller.isVerified.value
|
||||||
|
? Colors.green
|
||||||
|
: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: TSizes.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
controller.verificationMessage.value,
|
||||||
|
style: TextStyle(
|
||||||
|
color:
|
||||||
|
controller.isVerified.value
|
||||||
|
? Colors.green
|
||||||
|
: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: TSizes.spaceBtwSections),
|
||||||
|
|
||||||
|
// Data verification button
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed:
|
||||||
|
controller.isVerifying.value
|
||||||
|
? null
|
||||||
|
: () => controller.verifyIdCardWithOCR(),
|
||||||
|
icon: const Icon(Icons.verified_user),
|
||||||
|
label: Text(
|
||||||
|
controller.isVerified.value
|
||||||
|
? 'Re-verify Personal Information'
|
||||||
|
: 'Verify Personal Information',
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: TColors.primary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
minimumSize: const Size(double.infinity, 50),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
|
||||||
|
// Face verification section
|
||||||
|
Text(
|
||||||
|
'Face Verification',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeLg,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: TColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'Verify that your face matches with your ID card photo',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
color: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
|
||||||
|
// Face Verification Status
|
||||||
|
Obx(
|
||||||
|
() =>
|
||||||
|
controller.isVerifyingFace.value
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
vertical: TSizes.spaceBtwItems,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
SizedBox(height: TSizes.sm),
|
||||||
|
Text('Comparing face with ID photo...'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Face match verification button
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed:
|
||||||
|
controller.isVerifyingFace.value ||
|
||||||
|
controller.isFaceVerified.value
|
||||||
|
? null
|
||||||
|
: () => controller.verifyFaceMatch(),
|
||||||
|
icon: const Icon(Icons.face_retouching_natural),
|
||||||
|
label: Text(
|
||||||
|
controller.isFaceVerified.value
|
||||||
|
? 'Face Verified Successfully'
|
||||||
|
: 'Verify Face Match',
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor:
|
||||||
|
controller.isFaceVerified.value
|
||||||
|
? Colors.green
|
||||||
|
: TColors.primary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
minimumSize: const Size(double.infinity, 50),
|
||||||
|
disabledBackgroundColor:
|
||||||
|
controller.isFaceVerified.value
|
||||||
|
? Colors.green.withOpacity(0.7)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Face Verification Message
|
||||||
|
Obx(
|
||||||
|
() =>
|
||||||
|
controller.faceVerificationMessage.value.isNotEmpty
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: TSizes.spaceBtwItems,
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(TSizes.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
controller.isFaceVerified.value
|
||||||
|
? Colors.green.withOpacity(0.1)
|
||||||
|
: Colors.red.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.borderRadiusSm,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Icon(
|
||||||
|
controller.isFaceVerified.value
|
||||||
|
? Icons.check_circle
|
||||||
|
: Icons.error,
|
||||||
|
color:
|
||||||
|
controller.isFaceVerified.value
|
||||||
|
? Colors.green
|
||||||
|
: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: TSizes.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
controller.faceVerificationMessage.value,
|
||||||
|
style: TextStyle(
|
||||||
|
color:
|
||||||
|
controller.isFaceVerified.value
|
||||||
|
? Colors.green
|
||||||
|
: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPlaceOfBirthField(
|
||||||
|
BuildContext context,
|
||||||
|
IdentityVerificationController controller,
|
||||||
|
) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Place of Birth', style: Theme.of(context).textTheme.bodyMedium),
|
||||||
|
const SizedBox(height: TSizes.xs),
|
||||||
|
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _navigateToCitySelection(context, controller),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: TSizes.md,
|
||||||
|
vertical: TSizes.md,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color:
|
||||||
|
controller.placeOfBirthError.value.isNotEmpty
|
||||||
|
? TColors.error
|
||||||
|
: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusLg),
|
||||||
|
color: Colors.transparent,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
controller.placeOfBirthController.text.isEmpty
|
||||||
|
? 'Select Place of Birth'
|
||||||
|
: controller.placeOfBirthController.text,
|
||||||
|
style: TextStyle(
|
||||||
|
color:
|
||||||
|
controller.placeOfBirthController.text.isEmpty
|
||||||
|
? Theme.of(context).textTheme.bodyMedium?.color
|
||||||
|
: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Icons.location_city,
|
||||||
|
size: TSizes.iconSm,
|
||||||
|
color: Theme.of(context).textTheme.bodyMedium?.color,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (controller.placeOfBirthError.value.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: TSizes.xs, left: TSizes.xs),
|
||||||
|
child: Text(
|
||||||
|
controller.placeOfBirthError.value,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: TColors.error,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateToCitySelection(
|
||||||
|
BuildContext context,
|
||||||
|
IdentityVerificationController controller,
|
||||||
|
) async {
|
||||||
|
final selectedCity = await Get.to<String>(() => const CitySelectionPage());
|
||||||
|
|
||||||
|
if (selectedCity != null && selectedCity.isNotEmpty) {
|
||||||
|
controller.placeOfBirthController.text = selectedCity;
|
||||||
|
controller.placeOfBirthError.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildGenderSelection(
|
||||||
|
BuildContext context,
|
||||||
|
IdentityVerificationController controller,
|
||||||
|
) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Gender', style: Theme.of(context).textTheme.bodyMedium),
|
||||||
|
const SizedBox(height: TSizes.xs),
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: RadioListTile<String>(
|
||||||
|
title: const Text('Male'),
|
||||||
|
value: 'Male',
|
||||||
|
groupValue: controller.selectedGender.value,
|
||||||
|
onChanged: (value) {
|
||||||
|
controller.selectedGender.value = value!;
|
||||||
|
controller.genderError.value = '';
|
||||||
|
},
|
||||||
|
activeColor: TColors.primary,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: RadioListTile<String>(
|
||||||
|
title: const Text('Female'),
|
||||||
|
value: 'Female',
|
||||||
|
groupValue: controller.selectedGender.value,
|
||||||
|
onChanged: (value) {
|
||||||
|
controller.selectedGender.value = value!;
|
||||||
|
controller.genderError.value = '';
|
||||||
|
},
|
||||||
|
activeColor: TColors.primary,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
if (controller.genderError.value.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: TSizes.xs, left: TSizes.xs),
|
||||||
|
child: Text(
|
||||||
|
controller.genderError.value,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: TColors.error,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBirthDatePicker(
|
||||||
|
BuildContext context,
|
||||||
|
IdentityVerificationController controller,
|
||||||
|
) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Birth Date', style: Theme.of(context).textTheme.bodyMedium),
|
||||||
|
const SizedBox(height: TSizes.xs),
|
||||||
|
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _showDatePicker(context, controller),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: TSizes.md,
|
||||||
|
vertical: TSizes.md,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color:
|
||||||
|
controller.birthDateError.value.isNotEmpty
|
||||||
|
? TColors.error
|
||||||
|
: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusLg),
|
||||||
|
color: Colors.transparent,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
controller.birthDateController.text.isEmpty
|
||||||
|
? 'Select Birth Date'
|
||||||
|
: controller.birthDateController.text,
|
||||||
|
style: TextStyle(
|
||||||
|
color:
|
||||||
|
controller.birthDateController.text.isEmpty
|
||||||
|
? Theme.of(context).textTheme.bodyMedium?.color
|
||||||
|
: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Icons.calendar_today,
|
||||||
|
size: TSizes.iconSm,
|
||||||
|
color: Theme.of(context).textTheme.bodyMedium?.color,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (controller.birthDateError.value.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: TSizes.xs, left: TSizes.xs),
|
||||||
|
child: Text(
|
||||||
|
controller.birthDateError.value,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: TColors.error,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showDatePicker(
|
||||||
|
BuildContext context,
|
||||||
|
IdentityVerificationController controller,
|
||||||
|
) async {
|
||||||
|
final results = await showCalendarDatePicker2Dialog(
|
||||||
|
context: context,
|
||||||
|
config: CalendarDatePicker2WithActionButtonsConfig(
|
||||||
|
calendarType: CalendarDatePicker2Type.single,
|
||||||
|
selectedDayHighlightColor: TColors.primary,
|
||||||
|
lastDate: DateTime.now(), // Can't select future dates
|
||||||
|
firstDate: DateTime(1900), // Reasonable minimum birth year
|
||||||
|
),
|
||||||
|
dialogSize: const Size(325, 400),
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
||||||
|
value:
|
||||||
|
controller.birthDateController.text.isNotEmpty
|
||||||
|
? [_parseDate(controller.birthDateController.text)]
|
||||||
|
: <DateTime?>[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (results != null && results.isNotEmpty && results[0] != null) {
|
||||||
|
final selectedDate = results[0]!;
|
||||||
|
final formattedDate = DateFormat('yyyy-MM-dd').format(selectedDate);
|
||||||
|
controller.birthDateController.text = formattedDate;
|
||||||
|
controller.birthDateError.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _parseDate(String dateStr) {
|
||||||
|
try {
|
||||||
|
return DateFormat('yyyy-MM-dd').parse(dateStr);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,856 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/image_verification_controller.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
|
class ImageVerificationStep extends StatelessWidget {
|
||||||
|
const ImageVerificationStep({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final controller = Get.find<ImageVerificationController>();
|
||||||
|
final mainController = Get.find<FormRegistrationController>();
|
||||||
|
final isOfficer = mainController.selectedRole.value?.isOfficer ?? false;
|
||||||
|
final String idCardType = isOfficer ? 'KTA' : 'KTP';
|
||||||
|
|
||||||
|
return Form(
|
||||||
|
key: controller.formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Identity Document Verification',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeLg,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: TColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'Please upload your identity documents for verification',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
color: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
|
||||||
|
// ID Card Upload Section
|
||||||
|
Text(
|
||||||
|
'$idCardType Upload',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeMd,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: TColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'Upload a clear image of your $idCardType',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
color: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.xs),
|
||||||
|
Text(
|
||||||
|
'Make sure all text and your photo are clearly visible',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeXs,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
color: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
|
||||||
|
// ID Card Upload Widget
|
||||||
|
_buildIdCardUploader(controller, isOfficer),
|
||||||
|
|
||||||
|
// Verification Status for ID Card
|
||||||
|
Obx(
|
||||||
|
() =>
|
||||||
|
controller.isVerifying.value &&
|
||||||
|
!controller.isUploadingIdCard.value
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
vertical: TSizes.spaceBtwItems,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
SizedBox(height: TSizes.sm),
|
||||||
|
Text('Validating your ID card...'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Verification Message for ID Card
|
||||||
|
Obx(
|
||||||
|
() =>
|
||||||
|
controller.idCardValidationMessage.value.isNotEmpty
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: TSizes.spaceBtwItems,
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(TSizes.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
controller.isIdCardValid.value
|
||||||
|
? Colors.green.withOpacity(0.1)
|
||||||
|
: Colors.red.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.borderRadiusSm,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Icon(
|
||||||
|
controller.isIdCardValid.value
|
||||||
|
? Icons.check_circle
|
||||||
|
: Icons.error,
|
||||||
|
color:
|
||||||
|
controller.isIdCardValid.value
|
||||||
|
? Colors.green
|
||||||
|
: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: TSizes.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
controller.idCardValidationMessage.value,
|
||||||
|
style: TextStyle(
|
||||||
|
color:
|
||||||
|
controller.isIdCardValid.value
|
||||||
|
? Colors.green
|
||||||
|
: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (controller.isIdCardValid.value &&
|
||||||
|
!controller.hasConfirmedIdCard.value) ...[
|
||||||
|
const SizedBox(height: TSizes.md),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed:
|
||||||
|
() => controller.confirmIdCardImage(),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
child: const Text('Confirm Image'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: TSizes.sm),
|
||||||
|
Expanded(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed:
|
||||||
|
() => controller.clearIdCardImage(),
|
||||||
|
child: const Text('Try Another Image'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (controller.hasConfirmedIdCard.value)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: TSizes.sm),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: Colors.green,
|
||||||
|
size: TSizes.iconSm,
|
||||||
|
),
|
||||||
|
SizedBox(width: TSizes.xs),
|
||||||
|
Text(
|
||||||
|
'Image confirmed',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.green,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: TSizes.spaceBtwSections),
|
||||||
|
|
||||||
|
// Selfie Upload Section
|
||||||
|
Text(
|
||||||
|
'Selfie Upload',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeMd,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: TColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'Take a clear selfie for identity verification',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
color: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.xs),
|
||||||
|
Text(
|
||||||
|
'Make sure your face is well-lit and clearly visible',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeXs,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
color: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
|
||||||
|
// Selfie Upload Widget
|
||||||
|
_buildSelfieUploader(controller),
|
||||||
|
|
||||||
|
// Verification Status for Selfie
|
||||||
|
Obx(
|
||||||
|
() =>
|
||||||
|
controller.isVerifyingFace.value &&
|
||||||
|
!controller.isUploadingSelfie.value
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
vertical: TSizes.spaceBtwItems,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
SizedBox(height: TSizes.sm),
|
||||||
|
Text('Validating your selfie...'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Verification Message for Selfie
|
||||||
|
Obx(
|
||||||
|
() =>
|
||||||
|
controller.selfieValidationMessage.value.isNotEmpty
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: TSizes.spaceBtwItems,
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(TSizes.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
controller.isSelfieValid.value
|
||||||
|
? Colors.green.withOpacity(0.1)
|
||||||
|
: Colors.red.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.borderRadiusSm,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Icon(
|
||||||
|
controller.isSelfieValid.value
|
||||||
|
? Icons.check_circle
|
||||||
|
: Icons.error,
|
||||||
|
color:
|
||||||
|
controller.isSelfieValid.value
|
||||||
|
? Colors.green
|
||||||
|
: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: TSizes.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
controller.selfieValidationMessage.value,
|
||||||
|
style: TextStyle(
|
||||||
|
color:
|
||||||
|
controller.isSelfieValid.value
|
||||||
|
? Colors.green
|
||||||
|
: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (controller.isSelfieValid.value &&
|
||||||
|
!controller.hasConfirmedSelfie.value) ...[
|
||||||
|
const SizedBox(height: TSizes.md),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed:
|
||||||
|
() => controller.confirmSelfieImage(),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
child: const Text('Confirm Image'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: TSizes.sm),
|
||||||
|
Expanded(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed:
|
||||||
|
() => controller.clearSelfieImage(),
|
||||||
|
child: const Text('Try Another Image'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (controller.hasConfirmedSelfie.value)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: TSizes.sm),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: Colors.green,
|
||||||
|
size: TSizes.iconSm,
|
||||||
|
),
|
||||||
|
SizedBox(width: TSizes.xs),
|
||||||
|
Text(
|
||||||
|
'Image confirmed',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.green,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Error Messages
|
||||||
|
Obx(() {
|
||||||
|
if (controller.idCardError.value.isNotEmpty) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: TSizes.sm),
|
||||||
|
child: Text(
|
||||||
|
controller.idCardError.value,
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (controller.selfieError.value.isNotEmpty) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: TSizes.sm),
|
||||||
|
child: Text(
|
||||||
|
controller.selfieError.value,
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildIdCardUploader(
|
||||||
|
ImageVerificationController controller,
|
||||||
|
bool isOfficer,
|
||||||
|
) {
|
||||||
|
final String idCardType = isOfficer ? 'KTA' : 'KTP';
|
||||||
|
|
||||||
|
return Obx(() {
|
||||||
|
// Determine border color based on error state or confirmation
|
||||||
|
final borderColor =
|
||||||
|
controller.idCardError.value.isNotEmpty
|
||||||
|
? TColors.error
|
||||||
|
: controller.hasConfirmedIdCard.value
|
||||||
|
? Colors.green
|
||||||
|
: Colors.grey.withOpacity(0.5);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (controller.idCardImage.value == null)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _showImageSourceDialog(controller, true),
|
||||||
|
child: Container(
|
||||||
|
height: 180,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
||||||
|
border: Border.all(
|
||||||
|
color:
|
||||||
|
controller.idCardError.value.isNotEmpty
|
||||||
|
? TColors.error
|
||||||
|
: Colors.grey.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
controller.isUploadingIdCard.value
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'Uploading...',
|
||||||
|
style: TextStyle(color: TColors.textSecondary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.add_a_photo,
|
||||||
|
size: TSizes.iconLg,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'Upload $idCardType Image',
|
||||||
|
style: TextStyle(
|
||||||
|
color: TColors.textSecondary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.xs),
|
||||||
|
Text(
|
||||||
|
'Tap to select an image',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
color: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Stack(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.borderRadiusMd,
|
||||||
|
),
|
||||||
|
border: Border.all(color: borderColor, width: 2),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.borderRadiusMd,
|
||||||
|
),
|
||||||
|
child: Image.file(
|
||||||
|
File(controller.idCardImage.value!.path),
|
||||||
|
height: 200,
|
||||||
|
width: double.infinity,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Loading overlay
|
||||||
|
if (controller.isUploadingIdCard.value ||
|
||||||
|
controller.isVerifying.value)
|
||||||
|
Container(
|
||||||
|
height: 200,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.borderRadiusMd,
|
||||||
|
),
|
||||||
|
color: Colors.black.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'Processing...',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!controller.hasConfirmedIdCard.value &&
|
||||||
|
!controller.isUploadingIdCard.value &&
|
||||||
|
!controller.isVerifying.value)
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => controller.clearIdCardImage(),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(TSizes.xs),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.black54,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.close,
|
||||||
|
color: Colors.white,
|
||||||
|
size: TSizes.iconSm,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
if (!controller.isIdCardValid.value &&
|
||||||
|
!controller.isVerifying.value &&
|
||||||
|
!controller.isUploadingIdCard.value)
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () => controller.validateIdCardImage(),
|
||||||
|
icon: const Icon(Icons.check_circle),
|
||||||
|
label: Text('Check $idCardType Validity'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: TColors.primary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
minimumSize: const Size(double.infinity, 50),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSelfieUploader(ImageVerificationController controller) {
|
||||||
|
return Obx(() {
|
||||||
|
// Determine border color based on error state or confirmation
|
||||||
|
final borderColor =
|
||||||
|
controller.selfieError.value.isNotEmpty
|
||||||
|
? TColors.error
|
||||||
|
: controller.hasConfirmedSelfie.value
|
||||||
|
? Colors.green
|
||||||
|
: Colors.grey.withOpacity(0.5);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (controller.selfieImage.value == null)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _showSelfieDialog(controller),
|
||||||
|
child: Container(
|
||||||
|
height: 180,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
||||||
|
border: Border.all(
|
||||||
|
color:
|
||||||
|
controller.selfieError.value.isNotEmpty
|
||||||
|
? TColors.error
|
||||||
|
: Colors.grey.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
controller.isUploadingSelfie.value
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'Uploading...',
|
||||||
|
style: TextStyle(color: TColors.textSecondary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.face,
|
||||||
|
size: TSizes.iconLg,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'Take a Selfie',
|
||||||
|
style: TextStyle(
|
||||||
|
color: TColors.textSecondary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.xs),
|
||||||
|
Text(
|
||||||
|
'Tap to open camera',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
color: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Stack(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.borderRadiusMd,
|
||||||
|
),
|
||||||
|
border: Border.all(color: borderColor, width: 2),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.borderRadiusMd,
|
||||||
|
),
|
||||||
|
child: Image.file(
|
||||||
|
File(controller.selfieImage.value!.path),
|
||||||
|
height: 200,
|
||||||
|
width: double.infinity,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Loading overlay
|
||||||
|
if (controller.isUploadingSelfie.value ||
|
||||||
|
controller.isVerifyingFace.value)
|
||||||
|
Container(
|
||||||
|
height: 200,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.borderRadiusMd,
|
||||||
|
),
|
||||||
|
color: Colors.black.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'Processing...',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!controller.hasConfirmedSelfie.value &&
|
||||||
|
!controller.isUploadingSelfie.value &&
|
||||||
|
!controller.isVerifyingFace.value)
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => controller.clearSelfieImage(),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(TSizes.xs),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.black54,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.close,
|
||||||
|
color: Colors.white,
|
||||||
|
size: TSizes.iconSm,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
if (!controller.isSelfieValid.value &&
|
||||||
|
!controller.isVerifyingFace.value &&
|
||||||
|
!controller.isUploadingSelfie.value)
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () => controller.validateSelfieImage(),
|
||||||
|
icon: const Icon(Icons.check_circle),
|
||||||
|
label: const Text('Check Selfie Validity'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: TColors.primary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
minimumSize: const Size(double.infinity, 50),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showImageSourceDialog(
|
||||||
|
ImageVerificationController controller,
|
||||||
|
bool isIdCard,
|
||||||
|
) {
|
||||||
|
final mainController = Get.find<FormRegistrationController>();
|
||||||
|
final String title =
|
||||||
|
isIdCard
|
||||||
|
? 'Select ${mainController.selectedRole.value?.isOfficer ?? false ? "KTA" : "KTP"} Image Source'
|
||||||
|
: 'Select Selfie Source';
|
||||||
|
|
||||||
|
final String message =
|
||||||
|
isIdCard
|
||||||
|
? 'Please ensure your ID card is clear, well-lit, and all text is readable'
|
||||||
|
: 'Please ensure your face is clearly visible and well-lit';
|
||||||
|
|
||||||
|
Get.dialog(
|
||||||
|
Dialog(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(TSizes.defaultSpace),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeLg,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.md),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
color: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_buildImageSourceOption(
|
||||||
|
icon: Icons.camera_alt,
|
||||||
|
label: 'Camera',
|
||||||
|
onTap: () {
|
||||||
|
if (isIdCard) {
|
||||||
|
controller.pickIdCardImage(ImageSource.camera);
|
||||||
|
} else {
|
||||||
|
controller.pickSelfieImage(ImageSource.camera);
|
||||||
|
}
|
||||||
|
Get.back();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (isIdCard) // Only show gallery option for ID card
|
||||||
|
_buildImageSourceOption(
|
||||||
|
icon: Icons.image,
|
||||||
|
label: 'Gallery',
|
||||||
|
onTap: () {
|
||||||
|
controller.pickIdCardImage(ImageSource.gallery);
|
||||||
|
Get.back();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSelfieDialog(ImageVerificationController controller) {
|
||||||
|
controller.pickSelfieImage(ImageSource.camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildImageSourceOption({
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(TSizes.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: TColors.primary.withOpacity(0.1),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: TColors.primary, size: TSizes.iconLg),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(label),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,75 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/officer_info_controller.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
import 'package:sigap/src/utils/validators/validation.dart';
|
||||||
|
|
||||||
|
class OfficerInfoStep extends StatelessWidget {
|
||||||
|
const OfficerInfoStep({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final controller = Get.find<OfficerInfoController>();
|
||||||
|
|
||||||
|
return Form(
|
||||||
|
key: controller.formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Officer Information',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeLg,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: TColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'Please provide your officer details',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
color: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
|
||||||
|
// NRP field
|
||||||
|
Obx(
|
||||||
|
() => CustomTextField(
|
||||||
|
label: 'NRP',
|
||||||
|
controller: controller.nrpController,
|
||||||
|
validator: TValidators.validateNRP,
|
||||||
|
errorText: controller.nrpError.value,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
hintText: 'e.g., 123456789',
|
||||||
|
onChanged: (value) {
|
||||||
|
controller.nrpController.text = value;
|
||||||
|
controller.nrpError.value = '';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Rank field
|
||||||
|
Obx(
|
||||||
|
() => CustomTextField(
|
||||||
|
label: 'Rank',
|
||||||
|
controller: controller.rankController,
|
||||||
|
validator: TValidators.validateRank,
|
||||||
|
errorText: controller.rankError.value,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
hintText: 'e.g., Captain',
|
||||||
|
onChanged: (value) {
|
||||||
|
controller.rankController.text = value;
|
||||||
|
controller.rankError.value = '';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,141 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/personal_info_controller.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
import 'package:sigap/src/utils/validators/validation.dart';
|
||||||
|
|
||||||
|
class PersonalInfoStep extends StatelessWidget {
|
||||||
|
const PersonalInfoStep({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final controller = Get.find<PersonalInfoController>();
|
||||||
|
|
||||||
|
return Form(
|
||||||
|
key: controller.formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Personal Information',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeLg,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: TColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'Please provide your personal details',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
color: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
|
||||||
|
// First Name field
|
||||||
|
Obx(
|
||||||
|
() => CustomTextField(
|
||||||
|
label: 'First Name',
|
||||||
|
controller: controller.firstNameController,
|
||||||
|
validator:
|
||||||
|
(value) =>
|
||||||
|
TValidators.validateUserInput('First name', value, 50),
|
||||||
|
errorText: controller.firstNameError.value,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
hintText: 'e.g., John',
|
||||||
|
onChanged: (value) {
|
||||||
|
controller.firstNameController.text = value;
|
||||||
|
controller.firstNameError.value = '';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Last Name field
|
||||||
|
Obx(
|
||||||
|
() => CustomTextField(
|
||||||
|
label: 'Last Name',
|
||||||
|
controller: controller.lastNameController,
|
||||||
|
validator:
|
||||||
|
(value) => TValidators.validateUserInput(
|
||||||
|
'Last name',
|
||||||
|
value,
|
||||||
|
50,
|
||||||
|
required: true,
|
||||||
|
),
|
||||||
|
errorText: controller.lastNameError.value,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
hintText: 'e.g., Doe',
|
||||||
|
onChanged: (value) {
|
||||||
|
controller.lastNameController.text = value;
|
||||||
|
controller.lastNameError.value = '';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Phone field
|
||||||
|
Obx(
|
||||||
|
() => CustomTextField(
|
||||||
|
label: 'Phone Number',
|
||||||
|
controller: controller.phoneController,
|
||||||
|
validator: TValidators.validatePhoneNumber,
|
||||||
|
errorText: controller.phoneError.value,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
hintText: 'e.g., 081234567890',
|
||||||
|
onChanged: (value) {
|
||||||
|
controller.phoneController.text = value;
|
||||||
|
controller.phoneError.value = '';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Address field
|
||||||
|
Obx(
|
||||||
|
() => CustomTextField(
|
||||||
|
label: 'Address',
|
||||||
|
controller: controller.addressController,
|
||||||
|
validator:
|
||||||
|
(value) =>
|
||||||
|
TValidators.validateUserInput('Address', value, 255),
|
||||||
|
errorText: controller.addressError.value,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
maxLines: 3,
|
||||||
|
hintText: 'e.g., 123 Main St, City, Country',
|
||||||
|
onChanged: (value) {
|
||||||
|
controller.addressController.text = value;
|
||||||
|
controller.addressError.value = '';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bio field
|
||||||
|
Obx(
|
||||||
|
() => CustomTextField(
|
||||||
|
label: 'Bio',
|
||||||
|
controller: controller.bioController,
|
||||||
|
validator:
|
||||||
|
(value) => TValidators.validateUserInput(
|
||||||
|
'Bio',
|
||||||
|
value,
|
||||||
|
255,
|
||||||
|
required: false,
|
||||||
|
),
|
||||||
|
errorText: controller.bioError.value,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
maxLines: 3,
|
||||||
|
hintText: 'Tell us a little about yourself (optional)',
|
||||||
|
onChanged: (value) {
|
||||||
|
controller.bioController.text = value;
|
||||||
|
controller.bioError.value = '';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,530 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/selfie_verification_controller.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
|
class SelfieVerificationStep extends StatelessWidget {
|
||||||
|
const SelfieVerificationStep({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final controller = Get.find<SelfieVerificationController>();
|
||||||
|
|
||||||
|
return Form(
|
||||||
|
key: controller.formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Selfie Verification',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeLg,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: TColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'Take a clear selfie for identity verification',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
color: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.xs),
|
||||||
|
Text(
|
||||||
|
'Make sure your face is well-lit and clearly visible',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeXs,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
color: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
|
||||||
|
// Selfie Upload Widget
|
||||||
|
_buildSelfieUploader(controller),
|
||||||
|
|
||||||
|
// Verification Status for Selfie
|
||||||
|
Obx(
|
||||||
|
() =>
|
||||||
|
controller.isVerifyingFace.value &&
|
||||||
|
!controller.isUploadingSelfie.value
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
vertical: TSizes.spaceBtwItems,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
SizedBox(height: TSizes.sm),
|
||||||
|
Text('Validating your selfie...'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Verification Message for Selfie
|
||||||
|
Obx(
|
||||||
|
() =>
|
||||||
|
controller.selfieValidationMessage.value.isNotEmpty
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: TSizes.spaceBtwItems,
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(TSizes.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
controller.isSelfieValid.value
|
||||||
|
? Colors.green.withOpacity(0.1)
|
||||||
|
: Colors.red.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.borderRadiusSm,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Icon(
|
||||||
|
controller.isSelfieValid.value
|
||||||
|
? Icons.check_circle
|
||||||
|
: Icons.error,
|
||||||
|
color:
|
||||||
|
controller.isSelfieValid.value
|
||||||
|
? Colors.green
|
||||||
|
: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: TSizes.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
controller.selfieValidationMessage.value,
|
||||||
|
style: TextStyle(
|
||||||
|
color:
|
||||||
|
controller.isSelfieValid.value
|
||||||
|
? Colors.green
|
||||||
|
: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (controller.isSelfieValid.value &&
|
||||||
|
!controller.hasConfirmedSelfie.value) ...[
|
||||||
|
const SizedBox(height: TSizes.md),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed:
|
||||||
|
() => controller.confirmSelfieImage(),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
child: const Text('Confirm Image'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: TSizes.sm),
|
||||||
|
Expanded(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed:
|
||||||
|
() => controller.clearSelfieImage(),
|
||||||
|
child: const Text('Try Another Image'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (controller.hasConfirmedSelfie.value)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: TSizes.sm),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: Colors.green,
|
||||||
|
size: TSizes.iconSm,
|
||||||
|
),
|
||||||
|
SizedBox(width: TSizes.xs),
|
||||||
|
Text(
|
||||||
|
'Image confirmed',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.green,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Error Messages
|
||||||
|
Obx(
|
||||||
|
() =>
|
||||||
|
controller.selfieError.value.isNotEmpty
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.only(top: TSizes.sm),
|
||||||
|
child: Text(
|
||||||
|
controller.selfieError.value,
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: TSizes.spaceBtwSections),
|
||||||
|
|
||||||
|
// Tips for taking a good selfie
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(TSizes.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: TColors.primary.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusSm),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Tips for a Good Selfie:',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: TColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.xs),
|
||||||
|
_buildTip('Find a well-lit area with even lighting'),
|
||||||
|
_buildTip('Hold the camera at eye level'),
|
||||||
|
_buildTip('Look directly at the camera'),
|
||||||
|
_buildTip('Ensure your entire face is visible'),
|
||||||
|
_buildTip('Remove glasses and face coverings'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTip(String text) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: TSizes.xs),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle, size: TSizes.iconXs, color: TColors.primary),
|
||||||
|
const SizedBox(width: TSizes.xs),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeXs,
|
||||||
|
color: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSelfieUploader(SelfieVerificationController controller) {
|
||||||
|
return Obx(() {
|
||||||
|
// Background color based on error state or confirmation
|
||||||
|
final backgroundColor =
|
||||||
|
controller.selfieError.value.isNotEmpty
|
||||||
|
? TColors.error.withOpacity(0.1)
|
||||||
|
: controller.hasConfirmedSelfie.value
|
||||||
|
? Colors.green.withOpacity(0.1)
|
||||||
|
: Colors.grey.withOpacity(0.1);
|
||||||
|
|
||||||
|
// Determine border color based on error state or confirmation
|
||||||
|
final borderColor =
|
||||||
|
controller.selfieError.value.isNotEmpty
|
||||||
|
? TColors.error
|
||||||
|
: controller.hasConfirmedSelfie.value
|
||||||
|
? Colors.green
|
||||||
|
: Colors.grey.withOpacity(0.5);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (controller.selfieImage.value == null)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _captureSelfie(controller),
|
||||||
|
child: Container(
|
||||||
|
height: 180,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor, // Using the dynamic background color
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
||||||
|
border: Border.all(
|
||||||
|
color: borderColor,
|
||||||
|
width:
|
||||||
|
controller.selfieError.value.isNotEmpty
|
||||||
|
? 2
|
||||||
|
: 1, // Thicker border for error state
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
controller.isUploadingSelfie.value
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'Processing...',
|
||||||
|
style: TextStyle(color: TColors.textSecondary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
controller.selfieError.value.isNotEmpty
|
||||||
|
? Icons.error_outline
|
||||||
|
: Icons.face,
|
||||||
|
size: TSizes.iconLg,
|
||||||
|
color:
|
||||||
|
controller.selfieError.value.isNotEmpty
|
||||||
|
? TColors.error
|
||||||
|
: Colors.grey,
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
controller.selfieError.value.isNotEmpty
|
||||||
|
? 'Error: Please try again'
|
||||||
|
: 'Take a Selfie',
|
||||||
|
style: TextStyle(
|
||||||
|
color:
|
||||||
|
controller.selfieError.value.isNotEmpty
|
||||||
|
? TColors.error
|
||||||
|
: TColors.textSecondary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.xs),
|
||||||
|
Text(
|
||||||
|
'Tap to open camera',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
color:
|
||||||
|
controller.selfieError.value.isNotEmpty
|
||||||
|
? TColors.error.withOpacity(0.8)
|
||||||
|
: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Stack(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.borderRadiusMd,
|
||||||
|
),
|
||||||
|
border: Border.all(color: borderColor, width: 2),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.borderRadiusMd -
|
||||||
|
2, // Adjust for border width
|
||||||
|
),
|
||||||
|
child: Image.file(
|
||||||
|
File(controller.selfieImage.value!.path),
|
||||||
|
height: 200,
|
||||||
|
width: double.infinity,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Error overlay for uploaded image
|
||||||
|
if (controller.selfieError.value.isNotEmpty &&
|
||||||
|
!controller.isUploadingSelfie.value &&
|
||||||
|
!controller.isVerifyingFace.value)
|
||||||
|
Container(
|
||||||
|
height: 200,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.borderRadiusMd -
|
||||||
|
2, // Adjust for border width
|
||||||
|
),
|
||||||
|
color: TColors.error.withOpacity(0.2),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
color: TColors.error,
|
||||||
|
size: TSizes.iconLg,
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'Invalid Selfie',
|
||||||
|
style: TextStyle(
|
||||||
|
color: TColors.error,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.xs),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: TSizes.md,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Please take a clearer selfie',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: TColors.error,
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Loading overlay
|
||||||
|
if (controller.isUploadingSelfie.value ||
|
||||||
|
controller.isVerifyingFace.value)
|
||||||
|
Container(
|
||||||
|
height: 200,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.borderRadiusMd -
|
||||||
|
2, // Adjust for border width
|
||||||
|
),
|
||||||
|
color: Colors.black.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'Processing...',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!controller.hasConfirmedSelfie.value &&
|
||||||
|
!controller.isUploadingSelfie.value &&
|
||||||
|
!controller.isVerifyingFace.value)
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => controller.clearSelfieImage(),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(TSizes.xs),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
controller.selfieError.value.isNotEmpty
|
||||||
|
? TColors.error
|
||||||
|
: Colors.black54,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.close,
|
||||||
|
color: Colors.white,
|
||||||
|
size: TSizes.iconSm,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
if (!controller.isSelfieValid.value &&
|
||||||
|
!controller.isVerifyingFace.value &&
|
||||||
|
!controller.isUploadingSelfie.value)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () => controller.validateSelfieImage(),
|
||||||
|
icon: const Icon(Icons.check_circle),
|
||||||
|
label: const Text('Check Selfie Validity'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: TColors.primary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: TSizes.sm),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () => _captureSelfie(controller),
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Retake'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor:
|
||||||
|
controller.selfieError.value.isNotEmpty
|
||||||
|
? TColors.error
|
||||||
|
: null,
|
||||||
|
side: BorderSide(
|
||||||
|
color:
|
||||||
|
controller.selfieError.value.isNotEmpty
|
||||||
|
? TColors.error
|
||||||
|
: Colors.grey.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _captureSelfie(SelfieVerificationController controller) {
|
||||||
|
controller.pickSelfieImage(ImageSource.camera);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,87 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/unit_info_controller.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/dropdown/custom_dropdown.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
import 'package:sigap/src/utils/validators/validation.dart';
|
||||||
|
|
||||||
|
class UnitInfoStep extends StatelessWidget {
|
||||||
|
const UnitInfoStep({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final controller = Get.find<UnitInfoController>();
|
||||||
|
|
||||||
|
return Form(
|
||||||
|
key: controller.formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Unit Information',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeLg,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: TColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'Please provide your unit details',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
color: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
|
||||||
|
// Position field
|
||||||
|
Obx(
|
||||||
|
() => CustomTextField(
|
||||||
|
label: 'Position',
|
||||||
|
controller: controller.positionController,
|
||||||
|
validator: TValidators.validatePosition,
|
||||||
|
errorText: controller.positionError.value,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
hintText: 'e.g., Commander',
|
||||||
|
onChanged: (value) {
|
||||||
|
controller.positionController.text = value;
|
||||||
|
controller.positionError.value = '';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Unit Dropdown
|
||||||
|
Obx(
|
||||||
|
() => CustomDropdown(
|
||||||
|
label: 'Unit',
|
||||||
|
items:
|
||||||
|
controller.availableUnits
|
||||||
|
.map(
|
||||||
|
(unit) => DropdownMenuItem(
|
||||||
|
value: unit.codeUnit,
|
||||||
|
child: Text(unit.name),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
value:
|
||||||
|
controller.unitIdController.text.isEmpty
|
||||||
|
? null
|
||||||
|
: controller.unitIdController.text,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
controller.unitIdController.text = value.toString();
|
||||||
|
controller.unitIdError.value = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
validator: (value) => TValidators.validateUnitId(value),
|
||||||
|
errorText: controller.unitIdError.value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,427 @@
|
||||||
|
// import 'package:flutter/material.dart';
|
||||||
|
// import 'package:flutter/services.dart';
|
||||||
|
// import 'package:get/get.dart';
|
||||||
|
// import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
||||||
|
// import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
|
||||||
|
// import 'package:sigap/src/features/personalization/data/models/index.dart';
|
||||||
|
// import 'package:sigap/src/shared/widgets/dropdown/custom_dropdown.dart';
|
||||||
|
// import 'package:sigap/src/shared/widgets/indicators/step_indicator/index.dart';
|
||||||
|
// import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
||||||
|
// import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
|
// import 'package:sigap/src/utils/validators/validation.dart';
|
||||||
|
|
||||||
|
// class FormRegistrationScreen extends StatelessWidget {
|
||||||
|
// const FormRegistrationScreen({super.key});
|
||||||
|
|
||||||
|
// @override
|
||||||
|
// Widget build(BuildContext context) {
|
||||||
|
// // Get the controller
|
||||||
|
// final controller = Get.find<FormRegistrationController>();
|
||||||
|
|
||||||
|
// // Set system overlay style
|
||||||
|
// SystemChrome.setSystemUIOverlayStyle(
|
||||||
|
// const SystemUiOverlayStyle(
|
||||||
|
// statusBarColor: Colors.transparent,
|
||||||
|
// statusBarIconBrightness: Brightness.dark,
|
||||||
|
// ),
|
||||||
|
// );
|
||||||
|
|
||||||
|
// return Scaffold(
|
||||||
|
// backgroundColor: TColors.light,
|
||||||
|
// appBar: AppBar(
|
||||||
|
// backgroundColor: Colors.transparent,
|
||||||
|
// elevation: 0,
|
||||||
|
// title: Obx(
|
||||||
|
// () => Text(
|
||||||
|
// 'Complete Your ${controller.selectedRole.value?.name ?? ""} Profile',
|
||||||
|
// style: TextStyle(
|
||||||
|
// color: TColors.textPrimary,
|
||||||
|
// fontWeight: FontWeight.bold,
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// centerTitle: true,
|
||||||
|
// leading: IconButton(
|
||||||
|
// icon: Icon(Icons.arrow_back, color: TColors.textPrimary),
|
||||||
|
// onPressed: () => Get.back(),
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// body: Obx(() {
|
||||||
|
// // Show loading while initializing
|
||||||
|
// if (controller.selectedRole.value == null) {
|
||||||
|
// return const Center(child: CircularProgressIndicator());
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return SafeArea(
|
||||||
|
// child: Column(
|
||||||
|
// children: [
|
||||||
|
// // Step indicator
|
||||||
|
// Padding(
|
||||||
|
// padding: const EdgeInsets.all(24.0),
|
||||||
|
// child: Obx(
|
||||||
|
// () => StepIndicator(
|
||||||
|
// currentStep: controller.currentStep.value,
|
||||||
|
// totalSteps: controller.stepFormKeys.length,
|
||||||
|
// stepTitles: _getStepTitles(controller.selectedRole.value!),
|
||||||
|
// onStepTapped: controller.goToStep,
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
|
||||||
|
// // Step content
|
||||||
|
// Expanded(
|
||||||
|
// child: SingleChildScrollView(
|
||||||
|
// child: Padding(
|
||||||
|
// padding: const EdgeInsets.all(24.0),
|
||||||
|
// child: Obx(() {
|
||||||
|
// return _buildStepContent(controller);
|
||||||
|
// }),
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
|
||||||
|
// // Navigation buttons
|
||||||
|
// Padding(
|
||||||
|
// padding: const EdgeInsets.all(24.0),
|
||||||
|
// child: Row(
|
||||||
|
// children: [
|
||||||
|
// // Back button
|
||||||
|
// Obx(
|
||||||
|
// () =>
|
||||||
|
// controller.currentStep.value > 0
|
||||||
|
// ? Expanded(
|
||||||
|
// child: Padding(
|
||||||
|
// padding: const EdgeInsets.only(right: 8.0),
|
||||||
|
// child: AuthButton(
|
||||||
|
// text: 'Previous',
|
||||||
|
// onPressed: controller.previousStep,
|
||||||
|
// isPrimary: false,
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// )
|
||||||
|
// : const SizedBox.shrink(),
|
||||||
|
// ),
|
||||||
|
|
||||||
|
// // Next/Submit button
|
||||||
|
// Expanded(
|
||||||
|
// child: Padding(
|
||||||
|
// padding: EdgeInsets.only(
|
||||||
|
// left: controller.currentStep.value > 0 ? 8.0 : 0.0,
|
||||||
|
// ),
|
||||||
|
// child: Obx(
|
||||||
|
// () => AuthButton(
|
||||||
|
// text:
|
||||||
|
// controller.currentStep.value ==
|
||||||
|
// controller.stepFormKeys.length - 1
|
||||||
|
// ? 'Submit'
|
||||||
|
// : 'Next',
|
||||||
|
// onPressed: controller.nextStep,
|
||||||
|
// isLoading: controller.isLoading.value,
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// ],
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// ],
|
||||||
|
// ),
|
||||||
|
// );
|
||||||
|
// }),
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// List<String> _getStepTitles(RoleModel role) {
|
||||||
|
// if (role.isOfficer) {
|
||||||
|
// return ['Personal', 'Officer Info', 'Unit Info'];
|
||||||
|
// } else {
|
||||||
|
// return ['Personal', 'Emergency'];
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Widget _buildStepContent(FormRegistrationController controller) {
|
||||||
|
// final isOfficer = controller.selectedRole.value?.isOfficer ?? false;
|
||||||
|
|
||||||
|
// switch (controller.currentStep.value) {
|
||||||
|
// case 0:
|
||||||
|
// return _buildPersonalInfoStep(controller);
|
||||||
|
// case 1:
|
||||||
|
// return isOfficer
|
||||||
|
// ? _buildOfficerInfoStep(controller)
|
||||||
|
// : _buildEmergencyContactStep(controller);
|
||||||
|
// case 2:
|
||||||
|
// // This step only exists for officers
|
||||||
|
// if (isOfficer) {
|
||||||
|
// return _buildOfficerAdditionalInfoStep(controller);
|
||||||
|
// }
|
||||||
|
// return const SizedBox.shrink();
|
||||||
|
// default:
|
||||||
|
// return const SizedBox.shrink();
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Widget _buildPersonalInfoStep(FormRegistrationController controller) {
|
||||||
|
// return Form(
|
||||||
|
// key: controller.stepFormKeys[0],
|
||||||
|
// child: Column(
|
||||||
|
// crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
// children: [
|
||||||
|
// Text(
|
||||||
|
// 'Personal Information',
|
||||||
|
// style: TextStyle(
|
||||||
|
// fontSize: 20,
|
||||||
|
// fontWeight: FontWeight.bold,
|
||||||
|
// color: TColors.textPrimary,
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// const SizedBox(height: 8),
|
||||||
|
// Text(
|
||||||
|
// 'Please provide your personal details',
|
||||||
|
// style: TextStyle(fontSize: 14, color: TColors.textSecondary),
|
||||||
|
// ),
|
||||||
|
// const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// // First Name field
|
||||||
|
// Obx(
|
||||||
|
// () => CustomTextField(
|
||||||
|
// label: 'First Name',
|
||||||
|
// controller: controller.firstNameController,
|
||||||
|
// validator:
|
||||||
|
// (value) =>
|
||||||
|
// TValidators.validateUserInput('First name', value, 50),
|
||||||
|
// errorText: controller.firstNameError.value,
|
||||||
|
// textInputAction: TextInputAction.next,
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
|
||||||
|
// // Last Name field
|
||||||
|
// Obx(
|
||||||
|
// () => CustomTextField(
|
||||||
|
// label: 'Last Name',
|
||||||
|
// controller: controller.lastNameController,
|
||||||
|
// validator:
|
||||||
|
// (value) => TValidators.validateUserInput(
|
||||||
|
// 'Last name',
|
||||||
|
// value,
|
||||||
|
// 50,
|
||||||
|
// required: false,
|
||||||
|
// ),
|
||||||
|
// errorText: controller.lastNameError.value,
|
||||||
|
// textInputAction: TextInputAction.next,
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
|
||||||
|
// // Phone field
|
||||||
|
// Obx(
|
||||||
|
// () => CustomTextField(
|
||||||
|
// label: 'Phone Number',
|
||||||
|
// controller: controller.phoneController,
|
||||||
|
// validator: TValidators.validatePhoneNumber,
|
||||||
|
// errorText: controller.phoneError.value,
|
||||||
|
// keyboardType: TextInputType.phone,
|
||||||
|
// textInputAction: TextInputAction.next,
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
|
||||||
|
// // Address field
|
||||||
|
// Obx(
|
||||||
|
// () => CustomTextField(
|
||||||
|
// label: 'Address',
|
||||||
|
// controller: controller.addressController,
|
||||||
|
// validator:
|
||||||
|
// (value) =>
|
||||||
|
// TValidators.validateUserInput('Address', value, 255),
|
||||||
|
// errorText: controller.addressError.value,
|
||||||
|
// textInputAction: TextInputAction.done,
|
||||||
|
// maxLines: 3,
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// ],
|
||||||
|
// ),
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Widget _buildEmergencyContactStep(FormRegistrationController controller) {
|
||||||
|
// return Form(
|
||||||
|
// key: controller.stepFormKeys[1],
|
||||||
|
// child: Column(
|
||||||
|
// crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
// children: [
|
||||||
|
// Text(
|
||||||
|
// 'Additional Information',
|
||||||
|
// style: TextStyle(
|
||||||
|
// fontSize: 20,
|
||||||
|
// fontWeight: FontWeight.bold,
|
||||||
|
// color: TColors.textPrimary,
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// const SizedBox(height: 8),
|
||||||
|
// Text(
|
||||||
|
// 'Please provide additional personal details',
|
||||||
|
// style: TextStyle(fontSize: 14, color: TColors.textSecondary),
|
||||||
|
// ),
|
||||||
|
// const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// // NIK field
|
||||||
|
// Obx(
|
||||||
|
// () => CustomTextField(
|
||||||
|
// label: 'NIK (Identity Number)',
|
||||||
|
// controller: controller.nikController,
|
||||||
|
// validator:
|
||||||
|
// (value) => TValidators.validateUserInput('NIK', value, 16),
|
||||||
|
// errorText: controller.nikError.value,
|
||||||
|
// textInputAction: TextInputAction.next,
|
||||||
|
// keyboardType: TextInputType.number,
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
|
||||||
|
// // Bio field
|
||||||
|
// Obx(
|
||||||
|
// () => CustomTextField(
|
||||||
|
// label: 'Bio',
|
||||||
|
// controller: controller.bioController,
|
||||||
|
// validator:
|
||||||
|
// (value) => TValidators.validateUserInput(
|
||||||
|
// 'Bio',
|
||||||
|
// value,
|
||||||
|
// 255,
|
||||||
|
// required: false,
|
||||||
|
// ),
|
||||||
|
// errorText: controller.bioError.value,
|
||||||
|
// textInputAction: TextInputAction.next,
|
||||||
|
// maxLines: 3,
|
||||||
|
// hintText: 'Tell us a little about yourself (optional)',
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
|
||||||
|
// // Birth Date field
|
||||||
|
// Obx(
|
||||||
|
// () => CustomTextField(
|
||||||
|
// label: 'Birth Date (YYYY-MM-DD)',
|
||||||
|
// controller: controller.birthDateController,
|
||||||
|
// validator:
|
||||||
|
// (value) =>
|
||||||
|
// TValidators.validateUserInput('Birth date', value, 10),
|
||||||
|
// errorText: controller.birthDateError.value,
|
||||||
|
// textInputAction: TextInputAction.done,
|
||||||
|
// keyboardType: TextInputType.datetime,
|
||||||
|
// hintText: 'e.g., 1990-01-31',
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// ],
|
||||||
|
// ),
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Widget _buildOfficerInfoStep(FormRegistrationController controller) {
|
||||||
|
// return Form(
|
||||||
|
// key: controller.stepFormKeys[1],
|
||||||
|
// child: Column(
|
||||||
|
// crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
// children: [
|
||||||
|
// Text(
|
||||||
|
// 'Officer Information',
|
||||||
|
// style: TextStyle(
|
||||||
|
// fontSize: 20,
|
||||||
|
// fontWeight: FontWeight.bold,
|
||||||
|
// color: TColors.textPrimary,
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// const SizedBox(height: 8),
|
||||||
|
// Text(
|
||||||
|
// 'Please provide your officer details',
|
||||||
|
// style: TextStyle(fontSize: 14, color: TColors.textSecondary),
|
||||||
|
// ),
|
||||||
|
// const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// // NRP field
|
||||||
|
// Obx(
|
||||||
|
// () => CustomTextField(
|
||||||
|
// label: 'NRP',
|
||||||
|
// controller: controller.nrpController,
|
||||||
|
// validator: TValidators.validateNRP,
|
||||||
|
// errorText: controller.nrpError.value,
|
||||||
|
// textInputAction: TextInputAction.next,
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
|
||||||
|
// // Rank field
|
||||||
|
// Obx(
|
||||||
|
// () => CustomTextField(
|
||||||
|
// label: 'Rank',
|
||||||
|
// controller: controller.rankController,
|
||||||
|
// validator: TValidators.validateRank,
|
||||||
|
// errorText: controller.rankError.value,
|
||||||
|
// textInputAction: TextInputAction.done,
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// ],
|
||||||
|
// ),
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Widget _buildOfficerAdditionalInfoStep(
|
||||||
|
// FormRegistrationController controller,
|
||||||
|
// ) {
|
||||||
|
// return Form(
|
||||||
|
// key: controller.stepFormKeys[2],
|
||||||
|
// child: Column(
|
||||||
|
// crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
// children: [
|
||||||
|
// Text(
|
||||||
|
// 'Unit Information',
|
||||||
|
// style: TextStyle(
|
||||||
|
// fontSize: 20,
|
||||||
|
// fontWeight: FontWeight.bold,
|
||||||
|
// color: TColors.textPrimary,
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// const SizedBox(height: 8),
|
||||||
|
// Text(
|
||||||
|
// 'Please provide your unit details',
|
||||||
|
// style: TextStyle(fontSize: 14, color: TColors.textSecondary),
|
||||||
|
// ),
|
||||||
|
// const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// // Position field
|
||||||
|
// Obx(
|
||||||
|
// () => CustomTextField(
|
||||||
|
// label: 'Position',
|
||||||
|
// controller: controller.positionController,
|
||||||
|
// validator: TValidators.validatePosition,
|
||||||
|
// errorText: controller.positionError.value,
|
||||||
|
// textInputAction: TextInputAction.next,
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
|
||||||
|
// // Unit Dropdown
|
||||||
|
// Obx(
|
||||||
|
// () => CustomDropdown(
|
||||||
|
// label: 'Unit',
|
||||||
|
// items:
|
||||||
|
// controller.availableUnits
|
||||||
|
// .map(
|
||||||
|
// (unit) => DropdownMenuItem(
|
||||||
|
// value: unit.codeUnit,
|
||||||
|
// child: Text(unit.name),
|
||||||
|
// ),
|
||||||
|
// )
|
||||||
|
// .toList(),
|
||||||
|
// value:
|
||||||
|
// controller.unitIdController.text.isEmpty
|
||||||
|
// ? null
|
||||||
|
// : controller.unitIdController.text,
|
||||||
|
// onChanged: (value) {
|
||||||
|
// if (value != null) {
|
||||||
|
// controller.unitIdController.text = value.toString();
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// validator: (value) => TValidators.validateUnitId(value),
|
||||||
|
// errorText: controller.unitIdError.value,
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// ],
|
||||||
|
// ),
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
// }
|
|
@ -4,6 +4,9 @@ import 'package:get_storage/get_storage.dart';
|
||||||
import 'package:sigap/src/cores/services/location_service.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/features/onboarding/data/dummy/onboarding_dummy.dart';
|
||||||
import 'package:sigap/src/utils/constants/app_routes.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
|
class OnboardingController extends GetxController
|
||||||
with GetSingleTickerProviderStateMixin {
|
with GetSingleTickerProviderStateMixin {
|
||||||
|
@ -93,22 +96,44 @@ class OnboardingController extends GetxController
|
||||||
isLocationChecking.value = true;
|
isLocationChecking.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
|
TFullScreenLoader.openLoadingDialog(
|
||||||
|
'Checking location...',
|
||||||
|
TImages.loader,
|
||||||
|
);
|
||||||
|
|
||||||
// Verify location is valid (in Jember and not mocked)
|
// Verify location is valid (in Jember and not mocked)
|
||||||
final isLocationValid =
|
final isLocationValid =
|
||||||
await _locationService.isLocationValidForFeature();
|
await _locationService.isLocationValidForFeature();
|
||||||
|
|
||||||
if (isLocationValid) {
|
TFullScreenLoader.stopLoading();
|
||||||
|
|
||||||
|
if (!isLocationValid) {
|
||||||
// If location is valid, proceed to role selection
|
// If location is valid, proceed to role selection
|
||||||
Get.offAllNamed(AppRoutes.roleSelection);
|
Get.offAllNamed(AppRoutes.roleSelection);
|
||||||
|
|
||||||
|
TLoaders.successSnackBar(
|
||||||
|
title: 'Location Valid',
|
||||||
|
message: 'Checking location was successful',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// If location is invalid, show warning screen
|
// If location is invalid, show warning screen
|
||||||
Get.offAllNamed(AppRoutes.locationWarning);
|
Get.offAllNamed(AppRoutes.locationWarning);
|
||||||
|
|
||||||
|
TLoaders.errorSnackBar(
|
||||||
|
title: 'Location Invalid',
|
||||||
|
message:
|
||||||
|
'Please enable location services or ensure you are in Jember area',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If there's an error, show the location warning screen
|
// If there's an error, show the location warning screen
|
||||||
|
// TFullScreenLoader.stopLoading();
|
||||||
Get.offAllNamed(AppRoutes.locationWarning);
|
Get.offAllNamed(AppRoutes.locationWarning);
|
||||||
} finally {
|
} finally {
|
||||||
isLocationChecking.value = false;
|
isLocationChecking.value = false;
|
||||||
|
// TFullScreenLoader.stopLoading();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/cores/services/location_service.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/app_routes.dart';
|
||||||
import 'package:sigap/src/utils/constants/colors.dart';
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
class LocationWarningScreen extends StatelessWidget {
|
class LocationWarningScreen extends StatelessWidget {
|
||||||
const LocationWarningScreen({super.key});
|
const LocationWarningScreen({super.key});
|
||||||
|
@ -13,51 +13,53 @@ class LocationWarningScreen extends StatelessWidget {
|
||||||
final isMocked = locationService.isMockedLocation.value;
|
final isMocked = locationService.isMockedLocation.value;
|
||||||
final isOutsideJember = !locationService.isInJember();
|
final isOutsideJember = !locationService.isInJember();
|
||||||
|
|
||||||
|
// Get theme data for consistent styling
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: TColors.light,
|
// Use theme background color instead of hardcoded color
|
||||||
|
backgroundColor: theme.scaffoldBackgroundColor,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24.0),
|
padding: const EdgeInsets.all(TSizes.defaultSpace),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Warning Icon
|
// Warning Icon - use theme error color
|
||||||
Icon(Icons.location_off, size: 80, color: TColors.error),
|
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(
|
Text(
|
||||||
'Location Restriction',
|
'Location Restriction',
|
||||||
style: TextStyle(
|
style: theme.textTheme.headlineMedium?.copyWith(
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: TColors.textPrimary,
|
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
|
||||||
// Warning Message
|
// Warning Message - use theme body style
|
||||||
Text(
|
Text(
|
||||||
isMocked
|
isMocked
|
||||||
? 'We detected that you are using a mock location app. Please disable it to continue.'
|
? 'We detected that you are using a mock location app. Please disable it to continue.'
|
||||||
: isOutsideJember
|
: isOutsideJember
|
||||||
? 'This application is only available within Jember region. Please try again when you are in Jember.'
|
? '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.',
|
: 'We could not verify your location. Please ensure your location services are enabled and try again.',
|
||||||
style: TextStyle(
|
style: theme.textTheme.bodyLarge?.copyWith(height: 1.5),
|
||||||
fontSize: 16,
|
|
||||||
color: TColors.textSecondary,
|
|
||||||
height: 1.5,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: TSizes.spaceBtwSections * 1.5),
|
||||||
|
|
||||||
// Try Again Button
|
// Try Again Button - full width
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 56,
|
height: 56,
|
||||||
|
@ -72,43 +74,47 @@ class LocationWarningScreen extends StatelessWidget {
|
||||||
'Location Issue',
|
'Location Issue',
|
||||||
'Your location is still not valid. Please ensure you are in Jember with location services enabled.',
|
'Your location is still not valid. Please ensure you are in Jember with location services enabled.',
|
||||||
snackPosition: SnackPosition.BOTTOM,
|
snackPosition: SnackPosition.BOTTOM,
|
||||||
backgroundColor: TColors.error.withOpacity(0.1),
|
backgroundColor: theme.colorScheme.error.withOpacity(
|
||||||
colorText: TColors.error,
|
0.1,
|
||||||
|
),
|
||||||
|
colorText: theme.colorScheme.error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: TColors.primary,
|
minimumSize: const Size.fromHeight(56),
|
||||||
foregroundColor: TColors.white,
|
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(TSizes.buttonRadius),
|
||||||
),
|
),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: Text(
|
||||||
'Try Again',
|
'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
|
// Exit App Button - full width
|
||||||
TextButton(
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// This will close the app
|
// This will close the app
|
||||||
Get.back();
|
Get.back();
|
||||||
},
|
},
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: theme.colorScheme.secondary,
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Exit App',
|
'Exit App',
|
||||||
style: TextStyle(
|
style: theme.textTheme.bodyLarge),
|
||||||
color: TColors.textSecondary,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -67,6 +67,9 @@ class CustomTextField extends StatelessWidget {
|
||||||
).textTheme.bodyMedium?.copyWith(color: TColors.textSecondary),
|
).textTheme.bodyMedium?.copyWith(color: TColors.textSecondary),
|
||||||
errorText:
|
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(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: TSizes.md,
|
horizontal: TSizes.md,
|
||||||
vertical: TSizes.md,
|
vertical: TSizes.md,
|
||||||
|
|
|
@ -1,7 +1,23 @@
|
||||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
|
|
||||||
class Endpoints {
|
class Endpoints {
|
||||||
// Base URL
|
// Base URL
|
||||||
static const devUrl = "";
|
static const devUrl = "";
|
||||||
static const String prodUrl = '';
|
static const String prodUrl = '';
|
||||||
|
|
||||||
static const String baseUrl = '$devUrl/api';
|
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';
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ class TSizes {
|
||||||
static const double iconLg = 32.0;
|
static const double iconLg = 32.0;
|
||||||
|
|
||||||
// Font sizes
|
// Font sizes
|
||||||
|
static const double fontSizeXs = 10.0;
|
||||||
static const double fontSizeSm = 14.0;
|
static const double fontSizeSm = 14.0;
|
||||||
static const double fontSizeMd = 16.0;
|
static const double fontSizeMd = 16.0;
|
||||||
static const double fontSizeLg = 18.0;
|
static const double fontSizeLg = 18.0;
|
||||||
|
|
Loading…
Reference in New Issue