feat: Add image upload and selection functionality with ImageSourceDialog and ImageUploader widgets
- Implemented ImageSourceDialog for selecting image source (camera or gallery). - Created ImageUploader widget for displaying and managing image uploads, including error handling and validation. - Added TipsContainer widget for displaying helpful tips with customizable styles. - Developed OcrResultCard to present extracted information from KTP and KTA models. - Introduced ValidationMessageCard for showing validation messages with confirmation options. - Implemented FormKeyDebugger for tracking and debugging form keys in the application. - Added AnimatedSplashScreen for customizable splash screen transitions and navigation.
This commit is contained in:
parent
7f6f0c40b7
commit
6a85f75e3c
|
@ -1,30 +1,90 @@
|
||||||
import 'package:animated_splash_screen/animated_splash_screen.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:get_storage/get_storage.dart';
|
||||||
|
import 'package:logger/logger.dart';
|
||||||
import 'package:lottie/lottie.dart';
|
import 'package:lottie/lottie.dart';
|
||||||
|
import 'package:sigap/src/features/auth/data/repositories/authentication_repository.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/utils/constants/colors.dart';
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
import 'package:sigap/src/utils/constants/image_strings.dart';
|
import 'package:sigap/src/utils/constants/image_strings.dart';
|
||||||
import 'package:sigap/src/utils/helpers/helper_functions.dart';
|
import 'package:sigap/src/utils/helpers/helper_functions.dart';
|
||||||
|
|
||||||
class AnimatedSplashScreenWidget extends StatelessWidget {
|
class AnimatedSplashScreenWidget extends StatefulWidget {
|
||||||
const AnimatedSplashScreenWidget({super.key});
|
const AnimatedSplashScreenWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AnimatedSplashScreenWidget> createState() =>
|
||||||
|
_AnimatedSplashScreenWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AnimatedSplashScreenWidgetState extends State<AnimatedSplashScreenWidget>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _animationController;
|
||||||
|
late Animation<double> _animation;
|
||||||
|
final storage = GetStorage();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
_animationController = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 1500),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
_animation = CurvedAnimation(
|
||||||
|
parent: _animationController,
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
|
||||||
|
_animationController.forward();
|
||||||
|
|
||||||
|
// Delay for splash screen duration
|
||||||
|
Future.delayed(const Duration(milliseconds: 3500), () {
|
||||||
|
_handleNavigation();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_animationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleNavigation() async {
|
||||||
|
// Check if onboarding is completed
|
||||||
|
final isFirstTime = storage.read('isFirstTime') ?? false;
|
||||||
|
|
||||||
|
if (isFirstTime) {
|
||||||
|
// Navigate to onboarding if it's the first time
|
||||||
|
Get.offAll(() => const OnboardingScreen());
|
||||||
|
} else {
|
||||||
|
// Use the authentication repository to determine where to navigate
|
||||||
|
AuthenticationRepository.instance.screenRedirect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDark = THelperFunctions.isDarkMode(context);
|
final isDark = THelperFunctions.isDarkMode(context);
|
||||||
|
final isFirstTime = storage.read('isFirstTime') ?? false;
|
||||||
|
|
||||||
|
Logger().i('isFirstTime: $isFirstTime');
|
||||||
|
|
||||||
return AnimatedSplashScreen(
|
return Scaffold(
|
||||||
splash: Center(
|
backgroundColor: isDark ? TColors.dark : TColors.white,
|
||||||
child: Lottie.asset(
|
body: Center(
|
||||||
isDark ? TImages.darkSplashApp : TImages.lightSplashApp,
|
child: FadeTransition(
|
||||||
frameRate: FrameRate.max,
|
opacity: _animation,
|
||||||
repeat: true,
|
child: Lottie.asset(
|
||||||
|
isDark ? TImages.darkSplashApp : TImages.lightSplashApp,
|
||||||
|
frameRate: FrameRate.max,
|
||||||
|
repeat: true,
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
splashIconSize: 300,
|
|
||||||
duration: 3500,
|
|
||||||
nextScreen: const OnboardingScreen(),
|
|
||||||
backgroundColor: isDark ? TColors.dark : TColors.white,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -116,73 +116,390 @@ class AzureOCRService {
|
||||||
Map<String, String> _extractKtpInfo(Map<String, dynamic> ocrResult) {
|
Map<String, String> _extractKtpInfo(Map<String, dynamic> ocrResult) {
|
||||||
final Map<String, String> extractedInfo = {};
|
final Map<String, String> extractedInfo = {};
|
||||||
final List<String> allLines = _getAllTextLinesFromReadAPI(ocrResult);
|
final List<String> allLines = _getAllTextLinesFromReadAPI(ocrResult);
|
||||||
|
|
||||||
|
// Print all lines for debugging
|
||||||
|
print('Extracted ${allLines.length} lines from KTP');
|
||||||
for (int i = 0; i < allLines.length; i++) {
|
for (int i = 0; i < allLines.length; i++) {
|
||||||
String line = allLines[i].toLowerCase();
|
print('Line $i: ${allLines[i]}');
|
||||||
|
}
|
||||||
|
|
||||||
// Extract NIK (usually prefixed with "NIK" or "NIK:")
|
// Creating a single concatenated text for regex-based extraction
|
||||||
if (line.contains('nik') && i + 1 < allLines.length) {
|
final String fullText = allLines.join(' ').toLowerCase();
|
||||||
// NIK might be on the same line or the next line
|
|
||||||
String nikLine =
|
|
||||||
line.contains(':')
|
|
||||||
? line.split(':')[1].trim()
|
|
||||||
: allLines[i + 1].trim();
|
|
||||||
// Clean up the NIK (remove any non-numeric characters)
|
|
||||||
nikLine = nikLine.replaceAll(RegExp(r'[^0-9]'), '');
|
|
||||||
if (nikLine.length >= 16) {
|
|
||||||
// Standard ID card NIK length
|
|
||||||
extractedInfo['nik'] = nikLine;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract name (usually prefixed with "Nama" or "Nama:")
|
// NIK extraction - Look for pattern "NIK: 1234567890123456"
|
||||||
if (line.contains('nama') && i + 1 < allLines.length) {
|
RegExp nikRegex = RegExp(r'nik\s*:?\s*(\d{16})');
|
||||||
String name =
|
var nikMatch = nikRegex.firstMatch(fullText);
|
||||||
line.contains(':')
|
if (nikMatch != null && nikMatch.groupCount >= 1) {
|
||||||
? line.split(':')[1].trim()
|
extractedInfo['nik'] = nikMatch.group(1)!;
|
||||||
: allLines[i + 1].trim();
|
} else {
|
||||||
extractedInfo['nama'] = name;
|
// Try alternative format where NIK might be on a separate line
|
||||||
}
|
for (int i = 0; i < allLines.length; i++) {
|
||||||
|
if (allLines[i].toLowerCase().contains('nik')) {
|
||||||
// Extract birth date (usually prefixed with "Tanggal Lahir" or similar)
|
// NIK label found, check next line or same line after colon
|
||||||
if ((line.contains('lahir') || line.contains('ttl')) &&
|
String line = allLines[i].toLowerCase();
|
||||||
i + 1 < allLines.length) {
|
if (line.contains(':')) {
|
||||||
String birthInfo =
|
String potentialNik = line.split(':')[1].trim();
|
||||||
line.contains(':')
|
// Clean and validate
|
||||||
? line.split(':')[1].trim()
|
potentialNik = potentialNik.replaceAll(RegExp(r'[^0-9]'), '');
|
||||||
: allLines[i + 1].trim();
|
if (potentialNik.length == 16) {
|
||||||
// Try to extract date in format DD-MM-YYYY or similar
|
extractedInfo['nik'] = potentialNik;
|
||||||
RegExp dateRegex = RegExp(r'(\d{1,2}[-/\s\.]\d{1,2}[-/\s\.]\d{2,4})');
|
}
|
||||||
var match = dateRegex.firstMatch(birthInfo);
|
} else if (i + 1 < allLines.length) {
|
||||||
if (match != null) {
|
// Check next line
|
||||||
extractedInfo['tanggal_lahir'] = match.group(0)!;
|
String potentialNik = allLines[i + 1].trim();
|
||||||
}
|
// Clean and validate
|
||||||
}
|
potentialNik = potentialNik.replaceAll(RegExp(r'[^0-9]'), '');
|
||||||
|
if (potentialNik.length == 16) {
|
||||||
// Extract address (usually prefixed with "Alamat" or similar)
|
extractedInfo['nik'] = potentialNik;
|
||||||
if (line.contains('alamat') && i + 1 < allLines.length) {
|
}
|
||||||
// Address might span multiple lines, try to capture a reasonable amount
|
|
||||||
String address = '';
|
|
||||||
int j = line.contains(':') ? i : i + 1;
|
|
||||||
int maxLines = 3; // Capture up to 3 lines for address
|
|
||||||
|
|
||||||
while (j < allLines.length && j < i + maxLines) {
|
|
||||||
if (allLines[j].contains('provinsi') ||
|
|
||||||
allLines[j].contains('rt/rw') ||
|
|
||||||
allLines[j].contains('kota') ||
|
|
||||||
allLines[j].contains('kecamatan')) {
|
|
||||||
address += ' ${allLines[j].trim()}';
|
|
||||||
} else if (j > i) {
|
|
||||||
// Don't add the "Alamat:" line itself
|
|
||||||
address += ' ${allLines[j].trim()}';
|
|
||||||
}
|
}
|
||||||
j++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extractedInfo['alamat'] = address.trim();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Name extraction - Look for pattern "Nama: JOHN DOE"
|
||||||
|
for (int i = 0; i < allLines.length; i++) {
|
||||||
|
String line = allLines[i].toLowerCase();
|
||||||
|
if (line.contains('nama') || line.trim() == 'nama:') {
|
||||||
|
// Found name label, extract value
|
||||||
|
if (line.contains(':')) {
|
||||||
|
String name = line.split(':')[1].trim();
|
||||||
|
if (name.isNotEmpty) {
|
||||||
|
extractedInfo['nama'] = name;
|
||||||
|
} else if (i + 1 < allLines.length) {
|
||||||
|
// Name might be on next line
|
||||||
|
extractedInfo['nama'] = allLines[i + 1].trim();
|
||||||
|
}
|
||||||
|
} else if (i + 1 < allLines.length) {
|
||||||
|
// Name on next line
|
||||||
|
extractedInfo['nama'] = allLines[i + 1].trim();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Birth Place and Date - Look for pattern "Tempat/Tgl Lahir: JAKARTA, DD-MM-YYYY"
|
||||||
|
for (int i = 0; i < allLines.length; i++) {
|
||||||
|
String line = allLines[i].toLowerCase();
|
||||||
|
if (line.contains('tempat') && line.contains('lahir')) {
|
||||||
|
// Birth info found, might contain place and date
|
||||||
|
String birthInfo = '';
|
||||||
|
|
||||||
|
if (line.contains(':')) {
|
||||||
|
birthInfo = line.split(':')[1].trim();
|
||||||
|
} else if (i + 1 < allLines.length) {
|
||||||
|
birthInfo = allLines[i + 1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (birthInfo.isNotEmpty) {
|
||||||
|
// Try to separate place and date
|
||||||
|
if (birthInfo.contains(',')) {
|
||||||
|
List<String> parts = birthInfo.split(',');
|
||||||
|
if (parts.isNotEmpty) {
|
||||||
|
extractedInfo['birth_place'] = parts[0].trim();
|
||||||
|
}
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
// Extract date part
|
||||||
|
String datePart = parts[1].trim();
|
||||||
|
RegExp dateRegex = RegExp(
|
||||||
|
r'(\d{1,2}[-/\s\.]\d{1,2}[-/\s\.]\d{2,4})',
|
||||||
|
);
|
||||||
|
var dateMatch = dateRegex.firstMatch(datePart);
|
||||||
|
if (dateMatch != null) {
|
||||||
|
extractedInfo['birthDate'] = dateMatch.group(0)!;
|
||||||
|
extractedInfo['tanggal_lahir'] = dateMatch.group(0)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No comma separation, try to extract date directly
|
||||||
|
RegExp dateRegex = RegExp(
|
||||||
|
r'(\d{1,2}[-/\s\.]\d{1,2}[-/\s\.]\d{2,4})',
|
||||||
|
);
|
||||||
|
var dateMatch = dateRegex.firstMatch(birthInfo);
|
||||||
|
if (dateMatch != null) {
|
||||||
|
extractedInfo['tanggal_lahir'] = dateMatch.group(0)!;
|
||||||
|
extractedInfo['birthDate'] = dateMatch.group(0)!;
|
||||||
|
|
||||||
|
// Extract birth place by removing the date part
|
||||||
|
String place =
|
||||||
|
birthInfo.replaceAll(dateMatch.group(0)!, '').trim();
|
||||||
|
if (place.isNotEmpty) {
|
||||||
|
extractedInfo['birth_place'] = place;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gender extraction - Look for pattern "Jenis Kelamin: LAKI-LAKI"
|
||||||
|
for (int i = 0; i < allLines.length; i++) {
|
||||||
|
String line = allLines[i].toLowerCase();
|
||||||
|
if (line.contains('jenis') &&
|
||||||
|
(line.contains('kelamin') || line.contains('klamin'))) {
|
||||||
|
String gender = '';
|
||||||
|
if (line.contains(':')) {
|
||||||
|
gender = line.split(':')[1].trim();
|
||||||
|
} else if (i + 1 < allLines.length) {
|
||||||
|
gender = allLines[i + 1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gender.isNotEmpty) {
|
||||||
|
// Normalize gender
|
||||||
|
if (gender.contains('laki') ||
|
||||||
|
gender.contains('pria') ||
|
||||||
|
gender == 'l') {
|
||||||
|
extractedInfo['gender'] = 'Male';
|
||||||
|
extractedInfo['jenis_kelamin'] = 'LAKI-LAKI';
|
||||||
|
} else if (gender.contains('perempuan') ||
|
||||||
|
gender.contains('wanita') ||
|
||||||
|
gender == 'p') {
|
||||||
|
extractedInfo['gender'] = 'Female';
|
||||||
|
extractedInfo['jenis_kelamin'] = 'PEREMPUAN';
|
||||||
|
} else {
|
||||||
|
extractedInfo['gender'] = gender;
|
||||||
|
extractedInfo['jenis_kelamin'] = gender;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blood Type - "Gol. Darah: A"
|
||||||
|
for (int i = 0; i < allLines.length; i++) {
|
||||||
|
String line = allLines[i].toLowerCase();
|
||||||
|
if (line.contains('gol') && line.contains('darah')) {
|
||||||
|
String bloodType = '';
|
||||||
|
if (line.contains(':')) {
|
||||||
|
bloodType = line.split(':')[1].trim();
|
||||||
|
} else if (i + 1 < allLines.length) {
|
||||||
|
bloodType = allLines[i + 1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bloodType.isNotEmpty) {
|
||||||
|
// Normalize to just the letter (A, B, AB, O)
|
||||||
|
RegExp bloodTypeRegex = RegExp(r'([ABO]|AB)[-+]?');
|
||||||
|
var bloodTypeMatch = bloodTypeRegex.firstMatch(
|
||||||
|
bloodType.toUpperCase(),
|
||||||
|
);
|
||||||
|
if (bloodTypeMatch != null) {
|
||||||
|
extractedInfo['blood_type'] = bloodTypeMatch.group(0)!;
|
||||||
|
} else {
|
||||||
|
extractedInfo['blood_type'] = bloodType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address extraction
|
||||||
|
for (int i = 0; i < allLines.length; i++) {
|
||||||
|
String line = allLines[i].toLowerCase();
|
||||||
|
if (line.contains('alamat')) {
|
||||||
|
String address = '';
|
||||||
|
if (line.contains(':')) {
|
||||||
|
address = line.split(':')[1].trim();
|
||||||
|
} else if (i + 1 < allLines.length) {
|
||||||
|
address = allLines[i + 1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address might span multiple lines, collect until we hit another field
|
||||||
|
if (address.isNotEmpty) {
|
||||||
|
extractedInfo['address'] = address;
|
||||||
|
extractedInfo['alamat'] = address;
|
||||||
|
|
||||||
|
// Try to collect additional address lines
|
||||||
|
int j = i + 2; // Start from two lines after 'alamat'
|
||||||
|
while (j < allLines.length) {
|
||||||
|
String nextLine = allLines[j].toLowerCase();
|
||||||
|
// Stop if we encounter another field label
|
||||||
|
if (nextLine.contains(':') ||
|
||||||
|
nextLine.contains('rt/rw') ||
|
||||||
|
nextLine.contains('kel') ||
|
||||||
|
nextLine.contains('kec')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Add this line to address
|
||||||
|
extractedInfo['address'] =
|
||||||
|
'${extractedInfo['address'] ?? ''} ${allLines[j].trim()}';
|
||||||
|
extractedInfo['alamat'] =
|
||||||
|
'${extractedInfo['alamat'] ?? ''} ${allLines[j].trim()}';
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RT/RW extraction
|
||||||
|
for (int i = 0; i < allLines.length; i++) {
|
||||||
|
String line = allLines[i].toLowerCase();
|
||||||
|
if (line.contains('rt') && line.contains('rw')) {
|
||||||
|
String rtRw = '';
|
||||||
|
if (line.contains(':')) {
|
||||||
|
rtRw = line.split(':')[1].trim();
|
||||||
|
} else if (i + 1 < allLines.length) {
|
||||||
|
rtRw = allLines[i + 1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rtRw.isNotEmpty) {
|
||||||
|
extractedInfo['rt_rw'] = rtRw;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kel/Desa extraction
|
||||||
|
for (int i = 0; i < allLines.length; i++) {
|
||||||
|
String line = allLines[i].toLowerCase();
|
||||||
|
if ((line.contains('kel') && line.contains('desa')) ||
|
||||||
|
line.contains('kelurahan') ||
|
||||||
|
line.contains('desa')) {
|
||||||
|
String kelDesa = '';
|
||||||
|
if (line.contains(':')) {
|
||||||
|
kelDesa = line.split(':')[1].trim();
|
||||||
|
} else if (i + 1 < allLines.length) {
|
||||||
|
kelDesa = allLines[i + 1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kelDesa.isNotEmpty) {
|
||||||
|
extractedInfo['kelurahan'] = kelDesa;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kecamatan extraction
|
||||||
|
for (int i = 0; i < allLines.length; i++) {
|
||||||
|
String line = allLines[i].toLowerCase();
|
||||||
|
if (line.contains('kecamatan')) {
|
||||||
|
String kecamatan = '';
|
||||||
|
if (line.contains(':')) {
|
||||||
|
kecamatan = line.split(':')[1].trim();
|
||||||
|
} else if (i + 1 < allLines.length) {
|
||||||
|
kecamatan = allLines[i + 1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kecamatan.isNotEmpty) {
|
||||||
|
extractedInfo['kecamatan'] = kecamatan;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Religion extraction - "Agama: ISLAM"
|
||||||
|
for (int i = 0; i < allLines.length; i++) {
|
||||||
|
String line = allLines[i].toLowerCase();
|
||||||
|
if (line.contains('agama')) {
|
||||||
|
String religion = '';
|
||||||
|
if (line.contains(':')) {
|
||||||
|
religion = line.split(':')[1].trim();
|
||||||
|
} else if (i + 1 < allLines.length) {
|
||||||
|
religion = allLines[i + 1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (religion.isNotEmpty) {
|
||||||
|
extractedInfo['religion'] = religion;
|
||||||
|
extractedInfo['agama'] = religion;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marital Status - "Status Perkawinan: BELUM KAWIN"
|
||||||
|
for (int i = 0; i < allLines.length; i++) {
|
||||||
|
String line = allLines[i].toLowerCase();
|
||||||
|
if (line.contains('status') && line.contains('kawin')) {
|
||||||
|
String status = '';
|
||||||
|
if (line.contains(':')) {
|
||||||
|
status = line.split(':')[1].trim();
|
||||||
|
} else if (i + 1 < allLines.length) {
|
||||||
|
status = allLines[i + 1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.isNotEmpty) {
|
||||||
|
extractedInfo['marital_status'] = status;
|
||||||
|
extractedInfo['status_perkawinan'] = status;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Occupation - "Pekerjaan: KARYAWAN"
|
||||||
|
for (int i = 0; i < allLines.length; i++) {
|
||||||
|
String line = allLines[i].toLowerCase();
|
||||||
|
if (line.contains('pekerjaan')) {
|
||||||
|
String occupation = '';
|
||||||
|
if (line.contains(':')) {
|
||||||
|
occupation = line.split(':')[1].trim();
|
||||||
|
} else if (i + 1 < allLines.length) {
|
||||||
|
occupation = allLines[i + 1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (occupation.isNotEmpty) {
|
||||||
|
extractedInfo['occupation'] = occupation;
|
||||||
|
extractedInfo['pekerjaan'] = occupation;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nationality - "Kewarganegaraan: WNI"
|
||||||
|
for (int i = 0; i < allLines.length; i++) {
|
||||||
|
String line = allLines[i].toLowerCase();
|
||||||
|
if (line.contains('kewarganegaraan')) {
|
||||||
|
String nationality = '';
|
||||||
|
if (line.contains(':')) {
|
||||||
|
nationality = line.split(':')[1].trim();
|
||||||
|
} else if (i + 1 < allLines.length) {
|
||||||
|
nationality = allLines[i + 1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nationality.isNotEmpty) {
|
||||||
|
extractedInfo['nationality'] = nationality;
|
||||||
|
extractedInfo['kewarganegaraan'] = nationality;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validity Period - "Berlaku Hingga: SEUMUR HIDUP"
|
||||||
|
for (int i = 0; i < allLines.length; i++) {
|
||||||
|
String line = allLines[i].toLowerCase();
|
||||||
|
if (line.contains('berlaku') || line.contains('masa')) {
|
||||||
|
String validity = '';
|
||||||
|
if (line.contains(':')) {
|
||||||
|
validity = line.split(':')[1].trim();
|
||||||
|
} else if (i + 1 < allLines.length) {
|
||||||
|
validity = allLines[i + 1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validity.isNotEmpty) {
|
||||||
|
extractedInfo['validity_period'] = validity;
|
||||||
|
extractedInfo['berlaku_hingga'] = validity;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue date extraction
|
||||||
|
RegExp issueDateRegex = RegExp(r'(\d{1,2}[-/\s\.]\d{1,2}[-/\s\.]\d{4})');
|
||||||
|
for (int i = 0; i < allLines.length; i++) {
|
||||||
|
var match = issueDateRegex.firstMatch(allLines[i]);
|
||||||
|
if (match != null) {
|
||||||
|
String dateCandidate = match.group(0)!;
|
||||||
|
// Check if this is not already assigned as birth date
|
||||||
|
if (extractedInfo['birthDate'] != dateCandidate &&
|
||||||
|
extractedInfo['tanggal_lahir'] != dateCandidate) {
|
||||||
|
extractedInfo['issue_date'] = dateCandidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print extracted info for debugging
|
||||||
|
print('Extracted KTP info: ${extractedInfo.toString()}');
|
||||||
return extractedInfo;
|
return extractedInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,286 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
class DocumentIntelligenceService {
|
||||||
|
final String endpoint;
|
||||||
|
final String key;
|
||||||
|
final http.Client _client = http.Client();
|
||||||
|
|
||||||
|
DocumentIntelligenceService({
|
||||||
|
required this.endpoint,
|
||||||
|
required this.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generator function equivalent for getting text of spans
|
||||||
|
List<String> getTextOfSpans(String content, List<Map<String, dynamic>> spans) {
|
||||||
|
List<String> textList = [];
|
||||||
|
for (var span in spans) {
|
||||||
|
int offset = span['offset'];
|
||||||
|
int length = span['length'];
|
||||||
|
textList.add(content.substring(offset, offset + length));
|
||||||
|
}
|
||||||
|
return textList;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> analyzeDocument(String documentUrl) async {
|
||||||
|
try {
|
||||||
|
// Initial request to start document analysis
|
||||||
|
final initialResponse = await _client.post(
|
||||||
|
Uri.parse('$endpoint/documentModels/prebuilt-read:analyze'),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Ocp-Apim-Subscription-Key': key,
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
'urlSource': documentUrl,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (initialResponse.statusCode != 202) {
|
||||||
|
throw Exception('Failed to start analysis: ${initialResponse.body}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get operation location from response headers
|
||||||
|
String? operationLocation = initialResponse.headers['operation-location'];
|
||||||
|
if (operationLocation == null) {
|
||||||
|
throw Exception('Operation location not found in response headers');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll for results
|
||||||
|
return await _pollForResults(operationLocation);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Error analyzing document: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> _pollForResults(String operationLocation) async {
|
||||||
|
const int maxAttempts = 60; // Maximum polling attempts
|
||||||
|
const Duration pollInterval = Duration(seconds: 2);
|
||||||
|
|
||||||
|
for (int attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
final response = await _client.get(
|
||||||
|
Uri.parse(operationLocation),
|
||||||
|
headers: {
|
||||||
|
'Ocp-Apim-Subscription-Key': key,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('Failed to get operation status: ${response.body}');
|
||||||
|
}
|
||||||
|
|
||||||
|
final responseData = jsonDecode(response.body);
|
||||||
|
final String status = responseData['status'];
|
||||||
|
|
||||||
|
if (status == 'succeeded') {
|
||||||
|
return responseData['analyzeResult'];
|
||||||
|
} else if (status == 'failed') {
|
||||||
|
throw Exception('Document analysis failed: ${responseData['error']}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait before next poll
|
||||||
|
await Future.delayed(pollInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw Exception('Operation timed out after $maxAttempts attempts');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> processDocument(String documentUrl) async {
|
||||||
|
try {
|
||||||
|
final analyzeResult = await analyzeDocument(documentUrl);
|
||||||
|
|
||||||
|
final String? content = analyzeResult['content'];
|
||||||
|
final List<dynamic>? pages = analyzeResult['pages'];
|
||||||
|
final List<dynamic>? languages = analyzeResult['languages'];
|
||||||
|
final List<dynamic>? styles = analyzeResult['styles'];
|
||||||
|
|
||||||
|
// Process pages
|
||||||
|
if (pages == null || pages.isEmpty) {
|
||||||
|
print('No pages were extracted from the document.');
|
||||||
|
} else {
|
||||||
|
print('Pages:');
|
||||||
|
for (var page in pages) {
|
||||||
|
print('- Page ${page['pageNumber']} (unit: ${page['unit']})');
|
||||||
|
print(' ${page['width']}x${page['height']}, angle: ${page['angle']}');
|
||||||
|
|
||||||
|
final List<dynamic> lines = page['lines'] ?? [];
|
||||||
|
final List<dynamic> words = page['words'] ?? [];
|
||||||
|
|
||||||
|
print(' ${lines.length} lines, ${words.length} words');
|
||||||
|
|
||||||
|
if (lines.isNotEmpty) {
|
||||||
|
print(' Lines:');
|
||||||
|
for (var line in lines) {
|
||||||
|
print(' - "${line['content']}"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (words.isNotEmpty) {
|
||||||
|
print(' Words:');
|
||||||
|
for (var word in words) {
|
||||||
|
print(' - "${word['content']}"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process languages
|
||||||
|
if (languages == null || languages.isEmpty) {
|
||||||
|
print('No language spans were extracted from the document.');
|
||||||
|
} else {
|
||||||
|
print('Languages:');
|
||||||
|
for (var languageEntry in languages) {
|
||||||
|
print('- Found language: ${languageEntry['locale']} '
|
||||||
|
'(confidence: ${languageEntry['confidence']})');
|
||||||
|
|
||||||
|
if (content != null && languageEntry['spans'] != null) {
|
||||||
|
final spans = List<Map<String, dynamic>>.from(languageEntry['spans']);
|
||||||
|
final textList = getTextOfSpans(content, spans);
|
||||||
|
|
||||||
|
for (var text in textList) {
|
||||||
|
final escapedText = text
|
||||||
|
.replaceAll(RegExp(r'\r?\n'), '\\n')
|
||||||
|
.replaceAll('"', '\\"');
|
||||||
|
print(' - "$escapedText"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process styles
|
||||||
|
if (styles == null || styles.isEmpty) {
|
||||||
|
print('No text styles were extracted from the document.');
|
||||||
|
} else {
|
||||||
|
print('Styles:');
|
||||||
|
for (var style in styles) {
|
||||||
|
final bool isHandwritten = style['isHandwritten'] ?? false;
|
||||||
|
final double confidence = style['confidence'] ?? 0.0;
|
||||||
|
|
||||||
|
print('- Handwritten: ${isHandwritten ? "yes" : "no"} '
|
||||||
|
'(confidence=$confidence)');
|
||||||
|
|
||||||
|
if (content != null && style['spans'] != null) {
|
||||||
|
final spans = List<Map<String, dynamic>>.from(style['spans']);
|
||||||
|
final textList = getTextOfSpans(content, spans);
|
||||||
|
|
||||||
|
for (var text in textList) {
|
||||||
|
print(' - "$text"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('An error occurred: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example usage in a Flutter app
|
||||||
|
class DocumentAnalyzerApp {
|
||||||
|
static const String endpoint = "YOUR_FORM_RECOGNIZER_ENDPOINT";
|
||||||
|
static const String key = "YOUR_FORM_RECOGNIZER_KEY";
|
||||||
|
static const String formUrl =
|
||||||
|
"https://raw.githubusercontent.com/Azure-Samples/cognitive-services-REST-api-samples/master/curl/form-recognizer/rest-api/read.png";
|
||||||
|
|
||||||
|
static Future<void> main() async {
|
||||||
|
final service = DocumentIntelligenceService(
|
||||||
|
endpoint: endpoint,
|
||||||
|
key: key,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.processDocument(formUrl);
|
||||||
|
} finally {
|
||||||
|
service.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flutter Widget Example
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class DocumentAnalyzerWidget extends StatefulWidget {
|
||||||
|
const DocumentAnalyzerWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_DocumentAnalyzerWidgetState createState() => _DocumentAnalyzerWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DocumentAnalyzerWidgetState extends State<DocumentAnalyzerWidget> {
|
||||||
|
final DocumentIntelligenceService _service = DocumentIntelligenceService(
|
||||||
|
endpoint: "YOUR_FORM_RECOGNIZER_ENDPOINT",
|
||||||
|
key: "YOUR_FORM_RECOGNIZER_KEY",
|
||||||
|
);
|
||||||
|
|
||||||
|
bool _isLoading = false;
|
||||||
|
String _result = '';
|
||||||
|
|
||||||
|
Future<void> _analyzeDocument() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_result = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const String documentUrl =
|
||||||
|
"https://raw.githubusercontent.com/Azure-Samples/cognitive-services-REST-api-samples/master/curl/form-recognizer/rest-api/read.png";
|
||||||
|
|
||||||
|
await _service.processDocument(documentUrl);
|
||||||
|
setState(() {
|
||||||
|
_result = 'Document analysis completed successfully!';
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_result = 'Error: $e';
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_service.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text('Document Intelligence'),
|
||||||
|
),
|
||||||
|
body: Padding(
|
||||||
|
padding: EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _isLoading ? null : _analyzeDocument,
|
||||||
|
child: _isLoading
|
||||||
|
? CircularProgressIndicator(color: Colors.white)
|
||||||
|
: Text('Analyze Document'),
|
||||||
|
),
|
||||||
|
SizedBox(height: 20),
|
||||||
|
if (_result.isNotEmpty)
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Text(
|
||||||
|
_result,
|
||||||
|
style: TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,545 @@
|
||||||
|
{
|
||||||
|
"status": "succeeded",
|
||||||
|
"createdDateTime": "2025-05-22T09:03:26Z",
|
||||||
|
"lastUpdatedDateTime": "2025-05-22T09:03:27Z",
|
||||||
|
"analyzeResult": {
|
||||||
|
"apiVersion": "2024-11-30",
|
||||||
|
"modelId": "prebuilt-read",
|
||||||
|
"stringIndexType": "utf16CodeUnit",
|
||||||
|
"content": "KEPOLISIAN NEGARA REPUBLIK INDONESIA\nKARTU TANDA ANGGOTA\nMUSAHIR SH BRIPTU 89030022 POLDA LAMPUNG 6013 0106 2447 8534",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"pageNumber": 1,
|
||||||
|
"angle": 0,
|
||||||
|
"width": 300,
|
||||||
|
"height": 168,
|
||||||
|
"unit": "pixel",
|
||||||
|
"words": [
|
||||||
|
{
|
||||||
|
"content": "KEPOLISIAN",
|
||||||
|
"polygon": [
|
||||||
|
76,
|
||||||
|
22,
|
||||||
|
115,
|
||||||
|
21,
|
||||||
|
115,
|
||||||
|
30,
|
||||||
|
76,
|
||||||
|
31
|
||||||
|
],
|
||||||
|
"confidence": 0.692,
|
||||||
|
"span": {
|
||||||
|
"offset": 0,
|
||||||
|
"length": 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "NEGARA",
|
||||||
|
"polygon": [
|
||||||
|
117,
|
||||||
|
21,
|
||||||
|
145,
|
||||||
|
21,
|
||||||
|
145,
|
||||||
|
30,
|
||||||
|
117,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"confidence": 0.99,
|
||||||
|
"span": {
|
||||||
|
"offset": 11,
|
||||||
|
"length": 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "REPUBLIK",
|
||||||
|
"polygon": [
|
||||||
|
147,
|
||||||
|
21,
|
||||||
|
178,
|
||||||
|
20,
|
||||||
|
178,
|
||||||
|
29,
|
||||||
|
147,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"confidence": 0.932,
|
||||||
|
"span": {
|
||||||
|
"offset": 18,
|
||||||
|
"length": 8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "INDONESIA",
|
||||||
|
"polygon": [
|
||||||
|
179,
|
||||||
|
20,
|
||||||
|
220,
|
||||||
|
20,
|
||||||
|
220,
|
||||||
|
29,
|
||||||
|
179,
|
||||||
|
29
|
||||||
|
],
|
||||||
|
"confidence": 0.935,
|
||||||
|
"span": {
|
||||||
|
"offset": 27,
|
||||||
|
"length": 9
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "KARTU",
|
||||||
|
"polygon": [
|
||||||
|
74,
|
||||||
|
30,
|
||||||
|
112,
|
||||||
|
29,
|
||||||
|
113,
|
||||||
|
52,
|
||||||
|
74,
|
||||||
|
52
|
||||||
|
],
|
||||||
|
"confidence": 0.992,
|
||||||
|
"span": {
|
||||||
|
"offset": 37,
|
||||||
|
"length": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "TANDA",
|
||||||
|
"polygon": [
|
||||||
|
117,
|
||||||
|
29,
|
||||||
|
156,
|
||||||
|
28,
|
||||||
|
156,
|
||||||
|
52,
|
||||||
|
117,
|
||||||
|
52
|
||||||
|
],
|
||||||
|
"confidence": 0.991,
|
||||||
|
"span": {
|
||||||
|
"offset": 43,
|
||||||
|
"length": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "ANGGOTA",
|
||||||
|
"polygon": [
|
||||||
|
160,
|
||||||
|
28,
|
||||||
|
220,
|
||||||
|
28,
|
||||||
|
220,
|
||||||
|
50,
|
||||||
|
160,
|
||||||
|
51
|
||||||
|
],
|
||||||
|
"confidence": 0.992,
|
||||||
|
"span": {
|
||||||
|
"offset": 49,
|
||||||
|
"length": 7
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "MUSAHIR",
|
||||||
|
"polygon": [
|
||||||
|
85,
|
||||||
|
56,
|
||||||
|
140,
|
||||||
|
56,
|
||||||
|
140,
|
||||||
|
68,
|
||||||
|
85,
|
||||||
|
67
|
||||||
|
],
|
||||||
|
"confidence": 0.993,
|
||||||
|
"span": {
|
||||||
|
"offset": 57,
|
||||||
|
"length": 7
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "SH",
|
||||||
|
"polygon": [
|
||||||
|
144,
|
||||||
|
56,
|
||||||
|
161,
|
||||||
|
57,
|
||||||
|
160,
|
||||||
|
69,
|
||||||
|
144,
|
||||||
|
68
|
||||||
|
],
|
||||||
|
"confidence": 0.999,
|
||||||
|
"span": {
|
||||||
|
"offset": 65,
|
||||||
|
"length": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "BRIPTU",
|
||||||
|
"polygon": [
|
||||||
|
84,
|
||||||
|
67,
|
||||||
|
129,
|
||||||
|
68,
|
||||||
|
129,
|
||||||
|
78,
|
||||||
|
84,
|
||||||
|
79
|
||||||
|
],
|
||||||
|
"confidence": 0.992,
|
||||||
|
"span": {
|
||||||
|
"offset": 68,
|
||||||
|
"length": 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "89030022",
|
||||||
|
"polygon": [
|
||||||
|
84,
|
||||||
|
79,
|
||||||
|
142,
|
||||||
|
79,
|
||||||
|
142,
|
||||||
|
89,
|
||||||
|
84,
|
||||||
|
89
|
||||||
|
],
|
||||||
|
"confidence": 0.995,
|
||||||
|
"span": {
|
||||||
|
"offset": 75,
|
||||||
|
"length": 8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "POLDA",
|
||||||
|
"polygon": [
|
||||||
|
84,
|
||||||
|
89,
|
||||||
|
124,
|
||||||
|
89,
|
||||||
|
124,
|
||||||
|
101,
|
||||||
|
84,
|
||||||
|
101
|
||||||
|
],
|
||||||
|
"confidence": 0.994,
|
||||||
|
"span": {
|
||||||
|
"offset": 84,
|
||||||
|
"length": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "LAMPUNG",
|
||||||
|
"polygon": [
|
||||||
|
128,
|
||||||
|
89,
|
||||||
|
186,
|
||||||
|
90,
|
||||||
|
185,
|
||||||
|
101,
|
||||||
|
127,
|
||||||
|
101
|
||||||
|
],
|
||||||
|
"confidence": 0.995,
|
||||||
|
"span": {
|
||||||
|
"offset": 90,
|
||||||
|
"length": 7
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "6013",
|
||||||
|
"polygon": [
|
||||||
|
52,
|
||||||
|
103,
|
||||||
|
91,
|
||||||
|
103,
|
||||||
|
91,
|
||||||
|
118,
|
||||||
|
52,
|
||||||
|
118
|
||||||
|
],
|
||||||
|
"confidence": 0.992,
|
||||||
|
"span": {
|
||||||
|
"offset": 98,
|
||||||
|
"length": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "0106",
|
||||||
|
"polygon": [
|
||||||
|
103,
|
||||||
|
103,
|
||||||
|
141,
|
||||||
|
103,
|
||||||
|
141,
|
||||||
|
118,
|
||||||
|
102,
|
||||||
|
118
|
||||||
|
],
|
||||||
|
"confidence": 0.989,
|
||||||
|
"span": {
|
||||||
|
"offset": 103,
|
||||||
|
"length": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "2447",
|
||||||
|
"polygon": [
|
||||||
|
152,
|
||||||
|
103,
|
||||||
|
191,
|
||||||
|
103,
|
||||||
|
191,
|
||||||
|
118,
|
||||||
|
152,
|
||||||
|
118
|
||||||
|
],
|
||||||
|
"confidence": 0.992,
|
||||||
|
"span": {
|
||||||
|
"offset": 108,
|
||||||
|
"length": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "8534",
|
||||||
|
"polygon": [
|
||||||
|
203,
|
||||||
|
103,
|
||||||
|
243,
|
||||||
|
103,
|
||||||
|
243,
|
||||||
|
118,
|
||||||
|
202,
|
||||||
|
118
|
||||||
|
],
|
||||||
|
"confidence": 0.989,
|
||||||
|
"span": {
|
||||||
|
"offset": 113,
|
||||||
|
"length": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"content": "KEPOLISIAN NEGARA REPUBLIK INDONESIA",
|
||||||
|
"polygon": [
|
||||||
|
75,
|
||||||
|
21,
|
||||||
|
219,
|
||||||
|
20,
|
||||||
|
219,
|
||||||
|
28,
|
||||||
|
75,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"spans": [
|
||||||
|
{
|
||||||
|
"offset": 0,
|
||||||
|
"length": 36
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "KARTU TANDA ANGGOTA",
|
||||||
|
"polygon": [
|
||||||
|
73,
|
||||||
|
29,
|
||||||
|
219,
|
||||||
|
28,
|
||||||
|
220,
|
||||||
|
50,
|
||||||
|
73,
|
||||||
|
52
|
||||||
|
],
|
||||||
|
"spans": [
|
||||||
|
{
|
||||||
|
"offset": 37,
|
||||||
|
"length": 19
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "MUSAHIR SH",
|
||||||
|
"polygon": [
|
||||||
|
84,
|
||||||
|
56,
|
||||||
|
160,
|
||||||
|
56,
|
||||||
|
160,
|
||||||
|
68,
|
||||||
|
84,
|
||||||
|
67
|
||||||
|
],
|
||||||
|
"spans": [
|
||||||
|
{
|
||||||
|
"offset": 57,
|
||||||
|
"length": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "BRIPTU",
|
||||||
|
"polygon": [
|
||||||
|
84,
|
||||||
|
67,
|
||||||
|
129,
|
||||||
|
67,
|
||||||
|
129,
|
||||||
|
78,
|
||||||
|
84,
|
||||||
|
78
|
||||||
|
],
|
||||||
|
"spans": [
|
||||||
|
{
|
||||||
|
"offset": 68,
|
||||||
|
"length": 6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "89030022",
|
||||||
|
"polygon": [
|
||||||
|
83,
|
||||||
|
78,
|
||||||
|
141,
|
||||||
|
78,
|
||||||
|
141,
|
||||||
|
89,
|
||||||
|
83,
|
||||||
|
89
|
||||||
|
],
|
||||||
|
"spans": [
|
||||||
|
{
|
||||||
|
"offset": 75,
|
||||||
|
"length": 8
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "POLDA LAMPUNG",
|
||||||
|
"polygon": [
|
||||||
|
83,
|
||||||
|
89,
|
||||||
|
185,
|
||||||
|
89,
|
||||||
|
185,
|
||||||
|
101,
|
||||||
|
83,
|
||||||
|
101
|
||||||
|
],
|
||||||
|
"spans": [
|
||||||
|
{
|
||||||
|
"offset": 84,
|
||||||
|
"length": 13
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "6013 0106 2447 8534",
|
||||||
|
"polygon": [
|
||||||
|
52,
|
||||||
|
102,
|
||||||
|
242,
|
||||||
|
102,
|
||||||
|
242,
|
||||||
|
117,
|
||||||
|
52,
|
||||||
|
118
|
||||||
|
],
|
||||||
|
"spans": [
|
||||||
|
{
|
||||||
|
"offset": 98,
|
||||||
|
"length": 19
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"spans": [
|
||||||
|
{
|
||||||
|
"offset": 0,
|
||||||
|
"length": 117
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paragraphs": [
|
||||||
|
{
|
||||||
|
"spans": [
|
||||||
|
{
|
||||||
|
"offset": 0,
|
||||||
|
"length": 36
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"boundingRegions": [
|
||||||
|
{
|
||||||
|
"pageNumber": 1,
|
||||||
|
"polygon": [
|
||||||
|
75,
|
||||||
|
21,
|
||||||
|
219,
|
||||||
|
20,
|
||||||
|
219,
|
||||||
|
29,
|
||||||
|
75,
|
||||||
|
30
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"content": "KEPOLISIAN NEGARA REPUBLIK INDONESIA"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"spans": [
|
||||||
|
{
|
||||||
|
"offset": 37,
|
||||||
|
"length": 19
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"boundingRegions": [
|
||||||
|
{
|
||||||
|
"pageNumber": 1,
|
||||||
|
"polygon": [
|
||||||
|
73,
|
||||||
|
29,
|
||||||
|
220,
|
||||||
|
27,
|
||||||
|
220,
|
||||||
|
50,
|
||||||
|
73,
|
||||||
|
52
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"content": "KARTU TANDA ANGGOTA"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"spans": [
|
||||||
|
{
|
||||||
|
"offset": 57,
|
||||||
|
"length": 60
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"boundingRegions": [
|
||||||
|
{
|
||||||
|
"pageNumber": 1,
|
||||||
|
"polygon": [
|
||||||
|
52,
|
||||||
|
56,
|
||||||
|
242,
|
||||||
|
55,
|
||||||
|
242,
|
||||||
|
117,
|
||||||
|
52,
|
||||||
|
118
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"content": "MUSAHIR SH BRIPTU 89030022 POLDA LAMPUNG 6013 0106 2447 8534"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [],
|
||||||
|
"contentFormat": "text"
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -68,13 +68,18 @@ class AuthenticationRepository extends GetxController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updated screenRedirect method to accept arguments
|
/// Updated screenRedirect method to handle onboarding preferences
|
||||||
void screenRedirect({UserMetadataModel? arguments}) async {
|
void screenRedirect({UserMetadataModel? arguments}) async {
|
||||||
// Use addPostFrameCallback to ensure navigation happens after the build cycle
|
// Use addPostFrameCallback to ensure navigation happens after the build cycle
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
try {
|
try {
|
||||||
final session = _supabase.auth.currentSession;
|
final session = _supabase.auth.currentSession;
|
||||||
|
|
||||||
|
// Check if user has completed onboarding
|
||||||
|
final bool isFirstTime = storage.read('isFirstTime') ?? false;
|
||||||
|
|
||||||
|
Logger().i('isFirstTime screen redirect: $isFirstTime');
|
||||||
|
|
||||||
if (await _locationService.isLocationValidForFeature() == false) {
|
if (await _locationService.isLocationValidForFeature() == false) {
|
||||||
// Location is not valid, navigate to warning screen
|
// Location is not valid, navigate to warning screen
|
||||||
Get.offAllNamed(AppRoutes.locationWarning);
|
Get.offAllNamed(AppRoutes.locationWarning);
|
||||||
|
@ -100,13 +105,14 @@ class AuthenticationRepository extends GetxController {
|
||||||
Get.currentRoute != AppRoutes.onboarding) {
|
Get.currentRoute != AppRoutes.onboarding) {
|
||||||
bool biometricSuccess = await attemptBiometricLogin();
|
bool biometricSuccess = await attemptBiometricLogin();
|
||||||
if (!biometricSuccess) {
|
if (!biometricSuccess) {
|
||||||
// If not first time, go to sign in directly
|
// Check if onboarding is completed
|
||||||
// If first time, show onboarding first
|
if (isFirstTime) {
|
||||||
storage.writeIfNull('isFirstTime', true);
|
// Skip onboarding and go directly to sign in
|
||||||
// check if user is already logged in
|
Get.offAllNamed(AppRoutes.signIn);
|
||||||
storage.read('isFirstTime') != true
|
} else {
|
||||||
? Get.offAllNamed(AppRoutes.signIn)
|
// First time user, show onboarding
|
||||||
: Get.offAllNamed(AppRoutes.onboarding);
|
Get.offAllNamed(AppRoutes.onboarding);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:get_storage/get_storage.dart';
|
import 'package:get_storage/get_storage.dart';
|
||||||
import 'package:logger/logger.dart';
|
import 'package:logger/logger.dart';
|
||||||
|
@ -28,7 +29,9 @@ class FormRegistrationController extends GetxController {
|
||||||
late final IdentityVerificationController identityController;
|
late final IdentityVerificationController identityController;
|
||||||
late final OfficerInfoController? officerInfoController;
|
late final OfficerInfoController? officerInfoController;
|
||||||
late final UnitInfoController? unitInfoController;
|
late final UnitInfoController? unitInfoController;
|
||||||
|
|
||||||
|
late GlobalKey<FormState> formKey;
|
||||||
|
|
||||||
final storage = GetStorage();
|
final storage = GetStorage();
|
||||||
|
|
||||||
// Current step index
|
// Current step index
|
||||||
|
@ -291,18 +294,18 @@ class FormRegistrationController extends GetxController {
|
||||||
bool validateCurrentStep() {
|
bool validateCurrentStep() {
|
||||||
switch (currentStep.value) {
|
switch (currentStep.value) {
|
||||||
case 0:
|
case 0:
|
||||||
return personalInfoController.validate();
|
return personalInfoController.validate(formKey);
|
||||||
case 1:
|
case 1:
|
||||||
return idCardVerificationController.validate();
|
return idCardVerificationController.validate();
|
||||||
case 2:
|
case 2:
|
||||||
return selfieVerificationController.validate();
|
return selfieVerificationController.validate(formKey);
|
||||||
case 3:
|
case 3:
|
||||||
return selectedRole.value?.isOfficer == true
|
return selectedRole.value?.isOfficer == true
|
||||||
? officerInfoController!.validate()
|
? officerInfoController!.validate(formKey)
|
||||||
: identityController.validate();
|
: identityController.validate(formKey);
|
||||||
case 4:
|
case 4:
|
||||||
return selectedRole.value?.isOfficer == true
|
return selectedRole.value?.isOfficer == true
|
||||||
? unitInfoController!.validate()
|
? unitInfoController!.validate(formKey)
|
||||||
: true; // Should not reach here for non-officers
|
: true; // Should not reach here for non-officers
|
||||||
default:
|
default:
|
||||||
return true;
|
return true;
|
||||||
|
@ -374,16 +377,16 @@ class FormRegistrationController extends GetxController {
|
||||||
// Submit the complete form
|
// Submit the complete form
|
||||||
Future<void> submitForm() async {
|
Future<void> submitForm() async {
|
||||||
// Validate all steps
|
// Validate all steps
|
||||||
bool isValid = true;
|
bool isFormValid = true;
|
||||||
for (int i = 0; i < totalSteps; i++) {
|
for (int i = 0; i < totalSteps; i++) {
|
||||||
currentStep.value = i;
|
currentStep.value = i;
|
||||||
if (!validateCurrentStep()) {
|
if (!validateCurrentStep()) {
|
||||||
isValid = false;
|
isFormValid = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValid) return;
|
if (!isFormValid) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
|
|
|
@ -23,7 +23,7 @@ class SignInController extends GetxController {
|
||||||
|
|
||||||
final isLoading = false.obs;
|
final isLoading = false.obs;
|
||||||
|
|
||||||
GlobalKey<FormState> signinFormKey = GlobalKey<FormState>();
|
// GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
|
@ -42,7 +42,7 @@ class SignInController extends GetxController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sign in method
|
// Sign in method
|
||||||
Future<void> credentialsSignIn() async {
|
Future<void> credentialsSignIn(GlobalKey<FormState> formKey) async {
|
||||||
try {
|
try {
|
||||||
// Start loading
|
// Start loading
|
||||||
// TFullScreenLoader.openLoadingDialog(
|
// TFullScreenLoader.openLoadingDialog(
|
||||||
|
@ -61,7 +61,7 @@ class SignInController extends GetxController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Form validation
|
// Form validation
|
||||||
if (!signinFormKey.currentState!.validate()) {
|
if (!formKey.currentState!.validate()) {
|
||||||
// TFullScreenLoader.stopLoading();
|
// TFullScreenLoader.stopLoading();
|
||||||
emailError.value = '';
|
emailError.value = '';
|
||||||
passwordError.value = '';
|
passwordError.value = '';
|
||||||
|
@ -103,7 +103,7 @@ class SignInController extends GetxController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Google Sign In Authentication
|
// -- Google Sign In Authentication
|
||||||
Future<void> googleSignIn() async {
|
Future<void> googleSignIn(GlobalKey<FormState> formKey) async {
|
||||||
try {
|
try {
|
||||||
// Start loading
|
// Start loading
|
||||||
// TFullScreenLoader.openLoadingDialog(
|
// TFullScreenLoader.openLoadingDialog(
|
||||||
|
@ -122,7 +122,7 @@ class SignInController extends GetxController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Form validation
|
// Form validation
|
||||||
if (!signinFormKey.currentState!.validate()) {
|
if (!formKey.currentState!.validate()) {
|
||||||
// TFullScreenLoader.stopLoading();
|
// TFullScreenLoader.stopLoading();
|
||||||
emailError.value = '';
|
emailError.value = '';
|
||||||
passwordError.value = '';
|
passwordError.value = '';
|
||||||
|
@ -155,7 +155,7 @@ class SignInController extends GetxController {
|
||||||
|
|
||||||
// Navigate to sign up screen
|
// Navigate to sign up screen
|
||||||
void goToSignUp() {
|
void goToSignUp() {
|
||||||
Get.toNamed(AppRoutes.signUp);
|
Get.toNamed(AppRoutes.signupWithRole);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate to forgot password screen
|
// Navigate to forgot password screen
|
||||||
|
|
|
@ -169,14 +169,11 @@ class SignupWithRoleController extends GetxController {
|
||||||
// Sign up function
|
// Sign up function
|
||||||
/// Updated signup function with better error handling and argument passing
|
/// Updated signup function with better error handling and argument passing
|
||||||
void signUp(bool isOfficer) async {
|
void signUp(bool isOfficer) async {
|
||||||
if (!validateSignupForm()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
|
Logger().i('SignUp process started');
|
||||||
|
|
||||||
// Check connection
|
// Check network connection
|
||||||
final isConnected = await NetworkManager.instance.isConnected();
|
final isConnected = await NetworkManager.instance.isConnected();
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
TLoaders.errorSnackBar(
|
TLoaders.errorSnackBar(
|
||||||
|
@ -186,6 +183,19 @@ class SignupWithRoleController extends GetxController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!signupFormKey.currentState!.validate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check privacy policy
|
||||||
|
if (!privacyPolicy.value) {
|
||||||
|
TLoaders.warningSnackBar(
|
||||||
|
title: 'Privacy Policy',
|
||||||
|
message: 'Please accept the privacy policy to continue.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure we have a role selected
|
// Ensure we have a role selected
|
||||||
if (selectedRoleId.value.isEmpty) {
|
if (selectedRoleId.value.isEmpty) {
|
||||||
_updateSelectedRoleBasedOnType();
|
_updateSelectedRoleBasedOnType();
|
||||||
|
@ -208,42 +218,46 @@ class SignupWithRoleController extends GetxController {
|
||||||
profileStatus: 'incomplete',
|
profileStatus: 'incomplete',
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
// Create the account
|
||||||
// Create the account
|
final authResponse = await AuthenticationRepository.instance
|
||||||
final authResponse = await AuthenticationRepository.instance
|
.initialSignUp(
|
||||||
.initialSignUp(
|
email: emailController.text.trim(),
|
||||||
email: emailController.text.trim(),
|
password: passwordController.text.trim(),
|
||||||
password: passwordController.text.trim(),
|
initialData: initialMetadata,
|
||||||
initialData: initialMetadata,
|
);
|
||||||
);
|
|
||||||
|
|
||||||
// Validate response
|
// Validate response
|
||||||
if (authResponse.session == null || authResponse.user == null) {
|
if (authResponse.session == null || authResponse.user == null) {
|
||||||
throw Exception('Failed to create account. Please try again.');
|
|
||||||
}
|
|
||||||
|
|
||||||
final user = authResponse.user!;
|
|
||||||
Logger().d('Account created successfully for user: ${user.id}');
|
|
||||||
|
|
||||||
// Store temporary data for verification process
|
|
||||||
await _storeTemporaryData(authResponse, isOfficer);
|
|
||||||
|
|
||||||
// Navigate with arguments
|
|
||||||
AuthenticationRepository.instance.screenRedirect();
|
|
||||||
|
|
||||||
} catch (authError) {
|
|
||||||
Logger().e('Authentication error during signup: $authError');
|
|
||||||
TLoaders.errorSnackBar(
|
TLoaders.errorSnackBar(
|
||||||
title: 'Registration Failed',
|
title: 'Registration Failed',
|
||||||
message: _getReadableErrorMessage(authError.toString()),
|
message: 'Failed to create account. Please try again.',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final user = authResponse.user!;
|
||||||
|
Logger().d('Account created successfully for user: ${user.id}');
|
||||||
|
|
||||||
|
// Store temporary data for verification process
|
||||||
|
await _storeTemporaryData(authResponse, isOfficer);
|
||||||
|
|
||||||
|
// Navigate with arguments
|
||||||
|
Logger().i('Navigating to registration form');
|
||||||
|
// AuthenticationRepository.instance.screenRedirect();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Logger().e('Unexpected error during signup: $e');
|
Logger().e('Error during signup: $e');
|
||||||
|
String errorMessage = _getReadableErrorMessage(e.toString());
|
||||||
|
|
||||||
|
// Handle AuthException specifically
|
||||||
|
if (e is AuthException && e.message.contains('email')) {
|
||||||
|
emailError.value = 'Invalid email or already in use';
|
||||||
|
errorMessage =
|
||||||
|
'Email validation failed. Please check your email address.';
|
||||||
|
}
|
||||||
|
|
||||||
TLoaders.errorSnackBar(
|
TLoaders.errorSnackBar(
|
||||||
title: 'Registration Failed',
|
title: 'Registration Failed',
|
||||||
message: 'An unexpected error occurred. Please try again.',
|
message: errorMessage,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
|
|
|
@ -1,17 +1,17 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
|
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
|
||||||
import 'package:sigap/src/utils/constants/form_key.dart';
|
import 'package:sigap/src/features/daily-ops/data/models/models/kta_model.dart';
|
||||||
|
import 'package:sigap/src/features/personalization/data/models/models/ktp_model.dart';
|
||||||
|
|
||||||
class IdCardVerificationController extends GetxController {
|
class IdCardVerificationController extends GetxController {
|
||||||
// Singleton instance
|
// Singleton instance
|
||||||
static IdCardVerificationController get instance => Get.find();
|
static IdCardVerificationController get instance => Get.find();
|
||||||
|
|
||||||
// Static form key
|
// Static form key
|
||||||
final GlobalKey<FormState> formKey = TGlobalFormKey.idCardVerification();
|
// final GlobalKey<FormState> formKey = TGlobalFormKey.idCardVerification();
|
||||||
final AzureOCRService _ocrService = AzureOCRService();
|
final AzureOCRService _ocrService = AzureOCRService();
|
||||||
final bool isOfficer;
|
final bool isOfficer;
|
||||||
|
|
||||||
|
@ -26,6 +26,7 @@ class IdCardVerificationController extends GetxController {
|
||||||
final RxBool isVerifying = RxBool(false);
|
final RxBool isVerifying = RxBool(false);
|
||||||
final RxBool isIdCardValid = RxBool(false);
|
final RxBool isIdCardValid = RxBool(false);
|
||||||
final RxString idCardValidationMessage = RxString('');
|
final RxString idCardValidationMessage = RxString('');
|
||||||
|
final RxBool isFormValid = RxBool(true);
|
||||||
|
|
||||||
// Loading states for image uploading
|
// Loading states for image uploading
|
||||||
final RxBool isUploadingIdCard = RxBool(false);
|
final RxBool isUploadingIdCard = RxBool(false);
|
||||||
|
@ -33,25 +34,30 @@ class IdCardVerificationController extends GetxController {
|
||||||
// Confirmation status
|
// Confirmation status
|
||||||
final RxBool hasConfirmedIdCard = RxBool(false);
|
final RxBool hasConfirmedIdCard = RxBool(false);
|
||||||
|
|
||||||
|
// Add RxMap to store OCR extraction results
|
||||||
|
final RxMap<String, String> extractedInfo = RxMap<String, String>({});
|
||||||
|
final RxBool hasExtractedInfo = RxBool(false);
|
||||||
|
|
||||||
|
// Add model variables for the extracted data
|
||||||
|
final Rx<KtpModel?> ktpModel = Rx<KtpModel?>(null);
|
||||||
|
final Rx<KtaModel?> ktaModel = Rx<KtaModel?>(null);
|
||||||
|
|
||||||
bool validate() {
|
bool validate() {
|
||||||
clearErrors();
|
clearErrors();
|
||||||
|
|
||||||
// For this step, we just need to ensure ID card is uploaded and validated
|
|
||||||
bool isValid = true;
|
|
||||||
|
|
||||||
if (idCardImage.value == null) {
|
if (idCardImage.value == null) {
|
||||||
final idCardType = isOfficer ? 'KTA' : 'KTP';
|
final idCardType = isOfficer ? 'KTA' : 'KTP';
|
||||||
idCardError.value = 'Please upload your $idCardType image';
|
idCardError.value = 'Please upload your $idCardType image';
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
} else if (!isIdCardValid.value) {
|
} else if (!isIdCardValid.value) {
|
||||||
idCardError.value = 'Your ID card image is not valid';
|
idCardError.value = 'Your ID card image is not valid';
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
} else if (!hasConfirmedIdCard.value) {
|
} else if (!hasConfirmedIdCard.value) {
|
||||||
idCardError.value = 'Please confirm your ID card image';
|
idCardError.value = 'Please confirm your ID card image';
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return isValid;
|
return isFormValid.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearErrors() {
|
void clearErrors() {
|
||||||
|
@ -105,6 +111,14 @@ class IdCardVerificationController extends GetxController {
|
||||||
// Clear previous validation messages
|
// Clear previous validation messages
|
||||||
clearErrors();
|
clearErrors();
|
||||||
|
|
||||||
|
// Also clear previous extraction results
|
||||||
|
extractedInfo.clear();
|
||||||
|
hasExtractedInfo.value = false;
|
||||||
|
|
||||||
|
// Reset models
|
||||||
|
ktpModel.value = null;
|
||||||
|
ktaModel.value = null;
|
||||||
|
|
||||||
if (idCardImage.value == null) {
|
if (idCardImage.value == null) {
|
||||||
idCardError.value = 'Please upload an ID card image first';
|
idCardError.value = 'Please upload an ID card image first';
|
||||||
isIdCardValid.value = false;
|
isIdCardValid.value = false;
|
||||||
|
@ -125,6 +139,40 @@ class IdCardVerificationController extends GetxController {
|
||||||
isOfficer,
|
isOfficer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Store the extraction results
|
||||||
|
extractedInfo.assignAll(result);
|
||||||
|
hasExtractedInfo.value = result.isNotEmpty;
|
||||||
|
|
||||||
|
// Create model from extracted data
|
||||||
|
if (isOfficer) {
|
||||||
|
ktaModel.value = KtaModel(
|
||||||
|
name: result['nama'] ?? '',
|
||||||
|
nrp: result['nrp'] ?? '',
|
||||||
|
policeUnit: result['unit'] ?? result['kesatuan'] ?? '',
|
||||||
|
issueDate: result['tanggal_terbit'] ?? '',
|
||||||
|
cardNumber: result['nomor_kartu'] ?? '',
|
||||||
|
extraData: {
|
||||||
|
'pangkat': result['pangkat'] ?? '',
|
||||||
|
'tanggal_lahir': result['tanggal_lahir'] ?? '',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ktpModel.value = KtpModel(
|
||||||
|
nik: result['nik'] ?? '',
|
||||||
|
name: result['nama'] ?? '',
|
||||||
|
birthPlace: result['birth_place'] ?? result['birthPlace'] ?? '',
|
||||||
|
birthDate: result['tanggal_lahir'] ?? result['birthDate'] ?? '',
|
||||||
|
gender: result['gender'] ?? result['jenis_kelamin'] ?? '',
|
||||||
|
address: result['alamat'] ?? result['address'] ?? '',
|
||||||
|
nationality:
|
||||||
|
result['nationality'] ?? result['kewarganegaraan'] ?? 'WNI',
|
||||||
|
religion: result['religion'] ?? result['agama'],
|
||||||
|
occupation: result['occupation'] ?? result['pekerjaan'],
|
||||||
|
maritalStatus:
|
||||||
|
result['marital_status'] ?? result['status_perkawinan'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// If we get here without an exception, the image is likely valid
|
// If we get here without an exception, the image is likely valid
|
||||||
isImageValid = result.isNotEmpty;
|
isImageValid = result.isNotEmpty;
|
||||||
|
|
||||||
|
@ -182,6 +230,10 @@ class IdCardVerificationController extends GetxController {
|
||||||
isIdCardValid.value = false;
|
isIdCardValid.value = false;
|
||||||
idCardValidationMessage.value = '';
|
idCardValidationMessage.value = '';
|
||||||
hasConfirmedIdCard.value = false;
|
hasConfirmedIdCard.value = false;
|
||||||
|
extractedInfo.clear();
|
||||||
|
hasExtractedInfo.value = false;
|
||||||
|
ktpModel.value = null;
|
||||||
|
ktaModel.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm ID Card Image
|
// Confirm ID Card Image
|
||||||
|
|
|
@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/cores/services/azure_ocr_service.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/features/auth/presentasion/controllers/steps/image_verification_controller.dart';
|
||||||
import 'package:sigap/src/utils/constants/form_key.dart';
|
|
||||||
import 'package:sigap/src/utils/validators/validation.dart';
|
import 'package:sigap/src/utils/validators/validation.dart';
|
||||||
|
|
||||||
class IdentityVerificationController extends GetxController {
|
class IdentityVerificationController extends GetxController {
|
||||||
|
@ -10,9 +9,10 @@ class IdentityVerificationController extends GetxController {
|
||||||
static IdentityVerificationController get instance => Get.find();
|
static IdentityVerificationController get instance => Get.find();
|
||||||
|
|
||||||
// Static form key
|
// Static form key
|
||||||
final GlobalKey<FormState> formKey = TGlobalFormKey.identityVerification();
|
// final GlobalKey<FormState> formKey = TGlobalFormKey.identityVerification();
|
||||||
final AzureOCRService _ocrService = AzureOCRService();
|
final AzureOCRService _ocrService = AzureOCRService();
|
||||||
final bool isOfficer;
|
final bool isOfficer;
|
||||||
|
final RxBool isFormValid = RxBool(true);
|
||||||
|
|
||||||
IdentityVerificationController({required this.isOfficer});
|
IdentityVerificationController({required this.isOfficer});
|
||||||
|
|
||||||
|
@ -63,7 +63,7 @@ class IdentityVerificationController extends GetxController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool validate() {
|
bool validate(GlobalKey<FormState> formKey) {
|
||||||
clearErrors();
|
clearErrors();
|
||||||
|
|
||||||
if (formKey.currentState?.validate() ?? false) {
|
if (formKey.currentState?.validate() ?? false) {
|
||||||
|
@ -81,9 +81,6 @@ class IdentityVerificationController extends GetxController {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual validation as fallback
|
|
||||||
bool isValid = true;
|
|
||||||
|
|
||||||
if (!isOfficer) {
|
if (!isOfficer) {
|
||||||
final nikValidation = TValidators.validateUserInput(
|
final nikValidation = TValidators.validateUserInput(
|
||||||
'NIK',
|
'NIK',
|
||||||
|
@ -92,7 +89,7 @@ class IdentityVerificationController extends GetxController {
|
||||||
);
|
);
|
||||||
if (nikValidation != null) {
|
if (nikValidation != null) {
|
||||||
nikError.value = nikValidation;
|
nikError.value = nikValidation;
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate full name
|
// Validate full name
|
||||||
|
@ -103,7 +100,7 @@ class IdentityVerificationController extends GetxController {
|
||||||
);
|
);
|
||||||
if (fullNameValidation != null) {
|
if (fullNameValidation != null) {
|
||||||
fullNameError.value = fullNameValidation;
|
fullNameError.value = fullNameValidation;
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate place of birth
|
// Validate place of birth
|
||||||
|
@ -114,13 +111,13 @@ class IdentityVerificationController extends GetxController {
|
||||||
);
|
);
|
||||||
if (placeOfBirthValidation != null) {
|
if (placeOfBirthValidation != null) {
|
||||||
placeOfBirthError.value = placeOfBirthValidation;
|
placeOfBirthError.value = placeOfBirthValidation;
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate gender
|
// Validate gender
|
||||||
if (selectedGender.value.isEmpty) {
|
if (selectedGender.value.isEmpty) {
|
||||||
genderError.value = 'Gender is required';
|
genderError.value = 'Gender is required';
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate address
|
// Validate address
|
||||||
|
@ -131,7 +128,7 @@ class IdentityVerificationController extends GetxController {
|
||||||
);
|
);
|
||||||
if (addressValidation != null) {
|
if (addressValidation != null) {
|
||||||
addressError.value = addressValidation;
|
addressError.value = addressValidation;
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -144,7 +141,7 @@ class IdentityVerificationController extends GetxController {
|
||||||
);
|
);
|
||||||
if (bioValidation != null) {
|
if (bioValidation != null) {
|
||||||
bioError.value = bioValidation;
|
bioError.value = bioValidation;
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Birth date validation
|
// Birth date validation
|
||||||
|
@ -155,10 +152,10 @@ class IdentityVerificationController extends GetxController {
|
||||||
);
|
);
|
||||||
if (birthDateValidation != null) {
|
if (birthDateValidation != null) {
|
||||||
birthDateError.value = birthDateValidation;
|
birthDateError.value = birthDateValidation;
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return isValid && isVerified.value && isFaceVerified.value;
|
return isFormValid.value && isVerified.value && isFaceVerified.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearErrors() {
|
void clearErrors() {
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
|
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
|
||||||
|
@ -7,9 +6,10 @@ class ImageVerificationController extends GetxController {
|
||||||
// Singleton instance
|
// Singleton instance
|
||||||
static ImageVerificationController get instance => Get.find();
|
static ImageVerificationController get instance => Get.find();
|
||||||
|
|
||||||
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
// final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||||
final AzureOCRService _ocrService = AzureOCRService();
|
final AzureOCRService _ocrService = AzureOCRService();
|
||||||
final bool isOfficer;
|
final bool isOfficer;
|
||||||
|
final RxBool isFormValid = RxBool(true);
|
||||||
|
|
||||||
ImageVerificationController({required this.isOfficer});
|
ImageVerificationController({required this.isOfficer});
|
||||||
|
|
||||||
|
@ -41,32 +41,32 @@ class ImageVerificationController extends GetxController {
|
||||||
clearErrors();
|
clearErrors();
|
||||||
|
|
||||||
// For this step, we just need to ensure both images are uploaded and initially validated
|
// For this step, we just need to ensure both images are uploaded and initially validated
|
||||||
bool isValid = true;
|
|
||||||
|
|
||||||
if (idCardImage.value == null) {
|
if (idCardImage.value == null) {
|
||||||
final idCardType = isOfficer ? 'KTA' : 'KTP';
|
final idCardType = isOfficer ? 'KTA' : 'KTP';
|
||||||
idCardError.value = 'Please upload your $idCardType image';
|
idCardError.value = 'Please upload your $idCardType image';
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
} else if (!isIdCardValid.value) {
|
} else if (!isIdCardValid.value) {
|
||||||
idCardError.value = 'Your ID card image is not valid';
|
idCardError.value = 'Your ID card image is not valid';
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
} else if (!hasConfirmedIdCard.value) {
|
} else if (!hasConfirmedIdCard.value) {
|
||||||
idCardError.value = 'Please confirm your ID card image';
|
idCardError.value = 'Please confirm your ID card image';
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selfieImage.value == null) {
|
if (selfieImage.value == null) {
|
||||||
selfieError.value = 'Please take a selfie for verification';
|
selfieError.value = 'Please take a selfie for verification';
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
} else if (!isSelfieValid.value) {
|
} else if (!isSelfieValid.value) {
|
||||||
selfieError.value = 'Your selfie image is not valid';
|
selfieError.value = 'Your selfie image is not valid';
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
} else if (!hasConfirmedSelfie.value) {
|
} else if (!hasConfirmedSelfie.value) {
|
||||||
selfieError.value = 'Please confirm your selfie image';
|
selfieError.value = 'Please confirm your selfie image';
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return isValid;
|
return isFormValid.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearErrors() {
|
void clearErrors() {
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/utils/constants/form_key.dart';
|
|
||||||
import 'package:sigap/src/utils/validators/validation.dart';
|
import 'package:sigap/src/utils/validators/validation.dart';
|
||||||
|
|
||||||
class OfficerInfoController extends GetxController {
|
class OfficerInfoController extends GetxController {
|
||||||
|
@ -8,8 +7,9 @@ class OfficerInfoController extends GetxController {
|
||||||
static OfficerInfoController get instance => Get.find();
|
static OfficerInfoController get instance => Get.find();
|
||||||
|
|
||||||
// Static form key
|
// Static form key
|
||||||
final GlobalKey<FormState> formKey = TGlobalFormKey.officerInfo();
|
// final GlobalKey<FormState> formKey = TGlobalFormKey.officerInfo();
|
||||||
|
|
||||||
|
final RxBool isFormValid = RxBool(true);
|
||||||
// Controllers
|
// Controllers
|
||||||
final nrpController = TextEditingController();
|
final nrpController = TextEditingController();
|
||||||
final rankController = TextEditingController();
|
final rankController = TextEditingController();
|
||||||
|
@ -18,15 +18,14 @@ class OfficerInfoController extends GetxController {
|
||||||
final RxString nrpError = ''.obs;
|
final RxString nrpError = ''.obs;
|
||||||
final RxString rankError = ''.obs;
|
final RxString rankError = ''.obs;
|
||||||
|
|
||||||
bool validate() {
|
bool validate(GlobalKey<FormState> formKey) {
|
||||||
clearErrors();
|
clearErrors();
|
||||||
|
|
||||||
if (formKey.currentState?.validate() ?? false) {
|
if (formKey.currentState?.validate() ?? false) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual validation as fallback
|
|
||||||
bool isValid = true;
|
|
||||||
|
|
||||||
final nrpValidation = TValidators.validateUserInput(
|
final nrpValidation = TValidators.validateUserInput(
|
||||||
'NRP',
|
'NRP',
|
||||||
|
@ -35,7 +34,7 @@ class OfficerInfoController extends GetxController {
|
||||||
);
|
);
|
||||||
if (nrpValidation != null) {
|
if (nrpValidation != null) {
|
||||||
nrpError.value = nrpValidation;
|
nrpError.value = nrpValidation;
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final rankValidation = TValidators.validateUserInput(
|
final rankValidation = TValidators.validateUserInput(
|
||||||
|
@ -45,10 +44,10 @@ class OfficerInfoController extends GetxController {
|
||||||
);
|
);
|
||||||
if (rankValidation != null) {
|
if (rankValidation != null) {
|
||||||
rankError.value = rankValidation;
|
rankError.value = rankValidation;
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return isValid;
|
return isFormValid.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearErrors() {
|
void clearErrors() {
|
||||||
|
|
|
@ -1,15 +1,12 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/utils/constants/form_key.dart';
|
|
||||||
import 'package:sigap/src/utils/validators/validation.dart';
|
import 'package:sigap/src/utils/validators/validation.dart';
|
||||||
|
|
||||||
class PersonalInfoController extends GetxController {
|
class PersonalInfoController extends GetxController {
|
||||||
|
|
||||||
// Singleton instance
|
// Singleton instance
|
||||||
static PersonalInfoController get instance => Get.find();
|
static PersonalInfoController get instance => Get.find();
|
||||||
|
|
||||||
// Static form key
|
|
||||||
final GlobalKey<FormState> formKey = TGlobalFormKey.personalInfo();
|
|
||||||
|
|
||||||
// Controllers
|
// Controllers
|
||||||
final firstNameController = TextEditingController();
|
final firstNameController = TextEditingController();
|
||||||
final lastNameController = TextEditingController();
|
final lastNameController = TextEditingController();
|
||||||
|
@ -27,6 +24,9 @@ class PersonalInfoController extends GetxController {
|
||||||
final RxString addressError = ''.obs;
|
final RxString addressError = ''.obs;
|
||||||
|
|
||||||
|
|
||||||
|
// Manual validation as fallback
|
||||||
|
final RxBool isFormValid = RxBool(true);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
super.onInit();
|
super.onInit();
|
||||||
|
@ -51,15 +51,13 @@ class PersonalInfoController extends GetxController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool validate() {
|
bool validate(GlobalKey<FormState> formKey) {
|
||||||
clearErrors();
|
clearErrors();
|
||||||
|
|
||||||
if (formKey.currentState?.validate() ?? false) {
|
if (formKey.currentState?.validate() ?? false) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual validation as fallback
|
|
||||||
bool isValid = true;
|
|
||||||
|
|
||||||
final firstNameValidation = TValidators.validateUserInput(
|
final firstNameValidation = TValidators.validateUserInput(
|
||||||
'First name',
|
'First name',
|
||||||
|
@ -68,7 +66,7 @@ class PersonalInfoController extends GetxController {
|
||||||
);
|
);
|
||||||
if (firstNameValidation != null) {
|
if (firstNameValidation != null) {
|
||||||
firstNameError.value = firstNameValidation;
|
firstNameError.value = firstNameValidation;
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final lastNameValidation = TValidators.validateUserInput(
|
final lastNameValidation = TValidators.validateUserInput(
|
||||||
|
@ -79,7 +77,7 @@ class PersonalInfoController extends GetxController {
|
||||||
);
|
);
|
||||||
if (lastNameValidation != null) {
|
if (lastNameValidation != null) {
|
||||||
lastNameError.value = lastNameValidation;
|
lastNameError.value = lastNameValidation;
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final phoneValidation = TValidators.validatePhoneNumber(
|
final phoneValidation = TValidators.validatePhoneNumber(
|
||||||
|
@ -87,7 +85,7 @@ class PersonalInfoController extends GetxController {
|
||||||
);
|
);
|
||||||
if (phoneValidation != null) {
|
if (phoneValidation != null) {
|
||||||
phoneError.value = phoneValidation;
|
phoneError.value = phoneValidation;
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bio can be optional, so we validate with required: false
|
// Bio can be optional, so we validate with required: false
|
||||||
|
@ -99,7 +97,7 @@ class PersonalInfoController extends GetxController {
|
||||||
);
|
);
|
||||||
if (bioValidation != null) {
|
if (bioValidation != null) {
|
||||||
bioError.value = bioValidation;
|
bioError.value = bioValidation;
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final addressValidation = TValidators.validateUserInput(
|
final addressValidation = TValidators.validateUserInput(
|
||||||
|
@ -109,10 +107,10 @@ class PersonalInfoController extends GetxController {
|
||||||
);
|
);
|
||||||
if (addressValidation != null) {
|
if (addressValidation != null) {
|
||||||
addressError.value = addressValidation;
|
addressError.value = addressValidation;
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return isValid;
|
return isFormValid.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearErrors() {
|
void clearErrors() {
|
||||||
|
|
|
@ -4,19 +4,22 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
|
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
|
||||||
import 'package:sigap/src/utils/constants/form_key.dart';
|
|
||||||
|
|
||||||
class SelfieVerificationController extends GetxController {
|
class SelfieVerificationController extends GetxController {
|
||||||
// Singleton instance
|
// Singleton instance
|
||||||
static SelfieVerificationController get instance => Get.find();
|
static SelfieVerificationController get instance => Get.find();
|
||||||
|
|
||||||
// Static form key
|
// Static form key
|
||||||
final GlobalKey<FormState> formKey = TGlobalFormKey.selfieVerification();
|
// final GlobalKey<FormState> formKey = TGlobalFormKey.selfieVerification();
|
||||||
final AzureOCRService _ocrService = AzureOCRService();
|
final AzureOCRService _ocrService = AzureOCRService();
|
||||||
|
|
||||||
// Maximum allowed file size in bytes (4MB)
|
// Maximum allowed file size in bytes (4MB)
|
||||||
final int maxFileSizeBytes = 4 * 1024 * 1024; // 4MB in bytes
|
final int maxFileSizeBytes = 4 * 1024 * 1024; // 4MB in bytes
|
||||||
|
|
||||||
|
// For this step, we just need to ensure selfie is uploaded and validated
|
||||||
|
final RxBool isFormValid = RxBool(true);
|
||||||
|
|
||||||
|
|
||||||
// Face verification variables
|
// Face verification variables
|
||||||
final Rx<XFile?> selfieImage = Rx<XFile?>(null);
|
final Rx<XFile?> selfieImage = Rx<XFile?>(null);
|
||||||
final RxString selfieError = RxString('');
|
final RxString selfieError = RxString('');
|
||||||
|
@ -32,24 +35,22 @@ class SelfieVerificationController extends GetxController {
|
||||||
// Confirmation status
|
// Confirmation status
|
||||||
final RxBool hasConfirmedSelfie = RxBool(false);
|
final RxBool hasConfirmedSelfie = RxBool(false);
|
||||||
|
|
||||||
bool validate() {
|
bool validate(GlobalKey<FormState> formKey) {
|
||||||
clearErrors();
|
clearErrors();
|
||||||
|
|
||||||
// For this step, we just need to ensure selfie is uploaded and validated
|
|
||||||
bool isValid = true;
|
|
||||||
|
|
||||||
if (selfieImage.value == null) {
|
if (selfieImage.value == null) {
|
||||||
selfieError.value = 'Please take a selfie for verification';
|
selfieError.value = 'Please take a selfie for verification';
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
} else if (!isSelfieValid.value) {
|
} else if (!isSelfieValid.value) {
|
||||||
selfieError.value = 'Your selfie image is not valid';
|
selfieError.value = 'Your selfie image is not valid';
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
} else if (!hasConfirmedSelfie.value) {
|
} else if (!hasConfirmedSelfie.value) {
|
||||||
selfieError.value = 'Please confirm your selfie image';
|
selfieError.value = 'Please confirm your selfie image';
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return isValid;
|
return isFormValid.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearErrors() {
|
void clearErrors() {
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/features/daily-ops/data/models/index.dart';
|
import 'package:sigap/src/features/daily-ops/data/models/index.dart';
|
||||||
import 'package:sigap/src/utils/constants/form_key.dart';
|
|
||||||
import 'package:sigap/src/utils/validators/validation.dart';
|
import 'package:sigap/src/utils/validators/validation.dart';
|
||||||
|
|
||||||
class UnitInfoController extends GetxController {
|
class UnitInfoController extends GetxController {
|
||||||
|
@ -9,8 +8,9 @@ class UnitInfoController extends GetxController {
|
||||||
static UnitInfoController get instance => Get.find();
|
static UnitInfoController get instance => Get.find();
|
||||||
|
|
||||||
// Static form key
|
// Static form key
|
||||||
final GlobalKey<FormState> formKey = TGlobalFormKey.unitInfo();
|
// final GlobalKey<FormState> formKey = TGlobalFormKey.unitInfo();
|
||||||
|
// Manual validation as fallback
|
||||||
|
final RxBool isFormValid = RxBool(true);
|
||||||
// Controllers
|
// Controllers
|
||||||
final positionController = TextEditingController();
|
final positionController = TextEditingController();
|
||||||
final unitIdController = TextEditingController();
|
final unitIdController = TextEditingController();
|
||||||
|
@ -22,15 +22,14 @@ class UnitInfoController extends GetxController {
|
||||||
final RxString positionError = ''.obs;
|
final RxString positionError = ''.obs;
|
||||||
final RxString unitIdError = ''.obs;
|
final RxString unitIdError = ''.obs;
|
||||||
|
|
||||||
bool validate() {
|
bool validate(GlobalKey<FormState> formKey) {
|
||||||
clearErrors();
|
clearErrors();
|
||||||
|
|
||||||
if (formKey.currentState?.validate() ?? false) {
|
if (formKey.currentState?.validate() ?? false) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual validation as fallback
|
|
||||||
bool isValid = true;
|
|
||||||
|
|
||||||
final positionValidation = TValidators.validateUserInput(
|
final positionValidation = TValidators.validateUserInput(
|
||||||
'Position',
|
'Position',
|
||||||
|
@ -39,15 +38,15 @@ class UnitInfoController extends GetxController {
|
||||||
);
|
);
|
||||||
if (positionValidation != null) {
|
if (positionValidation != null) {
|
||||||
positionError.value = positionValidation;
|
positionError.value = positionValidation;
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (unitIdController.text.isEmpty) {
|
if (unitIdController.text.isEmpty) {
|
||||||
unitIdError.value = 'Please select a unit';
|
unitIdError.value = 'Please select a unit';
|
||||||
isValid = false;
|
isFormValid.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return isValid;
|
return isFormValid.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearErrors() {
|
void clearErrors() {
|
||||||
|
|
|
@ -0,0 +1,363 @@
|
||||||
|
import 'package:flutter/material.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/controllers/steps/id_card_verification_controller.dart';
|
||||||
|
import 'package:sigap/src/features/daily-ops/data/models/models/kta_model.dart';
|
||||||
|
import 'package:sigap/src/features/personalization/data/models/models/ktp_model.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/image_upload/image_source_dialog.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/image_upload/image_uploader.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/info/tips_container.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/verification/ocr_result_card.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/verification/validation_message_card.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) {
|
||||||
|
// Initialize form key
|
||||||
|
final controller = Get.find<IdCardVerificationController>();
|
||||||
|
final mainController = Get.find<FormRegistrationController>();
|
||||||
|
|
||||||
|
final formKey = GlobalKey<FormState>();
|
||||||
|
mainController.formKey = formKey;
|
||||||
|
|
||||||
|
final isOfficer = mainController.selectedRole.value?.isOfficer ?? false;
|
||||||
|
final String idCardType = isOfficer ? 'KTA' : 'KTP';
|
||||||
|
|
||||||
|
return Form(
|
||||||
|
key: formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildHeader(context, idCardType),
|
||||||
|
|
||||||
|
// 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(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ID Card Upload Widget
|
||||||
|
Obx(
|
||||||
|
() => ImageUploader(
|
||||||
|
image: controller.idCardImage.value,
|
||||||
|
title: 'Upload $idCardType Image',
|
||||||
|
subtitle: 'Tap to select an image (max 4MB)',
|
||||||
|
errorMessage: controller.idCardError.value,
|
||||||
|
isUploading: controller.isUploadingIdCard.value,
|
||||||
|
isVerifying: controller.isVerifying.value,
|
||||||
|
isConfirmed: controller.hasConfirmedIdCard.value,
|
||||||
|
onTapToSelect:
|
||||||
|
() => _showImageSourceDialog(controller, isOfficer),
|
||||||
|
onClear: controller.clearIdCardImage,
|
||||||
|
onValidate: controller.validateIdCardImage,
|
||||||
|
placeholderIcon: Icons.add_a_photo,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// OCR Results Card - Using appropriate model
|
||||||
|
Obx(() {
|
||||||
|
// Display the appropriate model data
|
||||||
|
if (controller.isVerifying.value == false &&
|
||||||
|
controller.idCardImage.value != null &&
|
||||||
|
controller.idCardValidationMessage.value.isNotEmpty) {
|
||||||
|
if (isOfficer && controller.ktaModel.value != null) {
|
||||||
|
return _buildKtaResultCard(
|
||||||
|
controller.ktaModel.value!,
|
||||||
|
controller.isIdCardValid.value,
|
||||||
|
);
|
||||||
|
} else if (!isOfficer && controller.ktpModel.value != null) {
|
||||||
|
return _buildKtpResultCard(
|
||||||
|
controller.ktpModel.value!,
|
||||||
|
controller.isIdCardValid.value,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Fallback to the regular OCR result card
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: TSizes.spaceBtwItems,
|
||||||
|
),
|
||||||
|
child: OcrResultCard(
|
||||||
|
extractedInfo: controller.extractedInfo,
|
||||||
|
isOfficer: isOfficer,
|
||||||
|
isValid: controller.isIdCardValid.value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Verification Message for ID Card
|
||||||
|
Obx(
|
||||||
|
() =>
|
||||||
|
controller.idCardValidationMessage.value.isNotEmpty
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: TSizes.spaceBtwItems,
|
||||||
|
),
|
||||||
|
child: ValidationMessageCard(
|
||||||
|
message: controller.idCardValidationMessage.value,
|
||||||
|
isValid: controller.isIdCardValid.value,
|
||||||
|
hasConfirmed: controller.hasConfirmedIdCard.value,
|
||||||
|
onConfirm: controller.confirmIdCardImage,
|
||||||
|
onTryAnother: controller.clearIdCardImage,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Tips Section
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
_buildIdCardTips(idCardType),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader(BuildContext context, String idCardType) {
|
||||||
|
return 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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildIdCardTips(String idCardType) {
|
||||||
|
return TipsContainer(
|
||||||
|
title: "Tips for a good $idCardType photo:",
|
||||||
|
tips: [
|
||||||
|
"Place the card on a dark, non-reflective surface",
|
||||||
|
"Ensure all four corners are visible",
|
||||||
|
"Make sure there's good lighting to avoid shadows",
|
||||||
|
"Your photo and all text should be clearly visible",
|
||||||
|
"Avoid using flash to prevent glare",
|
||||||
|
],
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
textColor: Colors.blue.shade800,
|
||||||
|
iconColor: Colors.blue,
|
||||||
|
borderColor: Colors.blue,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showImageSourceDialog(
|
||||||
|
IdCardVerificationController controller,
|
||||||
|
bool isOfficer,
|
||||||
|
) {
|
||||||
|
final String idCardType = isOfficer ? 'KTA' : 'KTP';
|
||||||
|
|
||||||
|
ImageSourceDialog.show(
|
||||||
|
title: 'Select $idCardType Image Source',
|
||||||
|
message:
|
||||||
|
'Please ensure your ID card is clear, well-lit, and all text is readable. Maximum file size: 4MB',
|
||||||
|
onSourceSelected: controller.pickIdCardImage,
|
||||||
|
galleryOption: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildKtpResultCard(KtpModel model, bool isValid) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: TSizes.spaceBtwItems),
|
||||||
|
child: Card(
|
||||||
|
elevation: 2,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
||||||
|
side: BorderSide(
|
||||||
|
color: isValid ? Colors.green : Colors.orange,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(TSizes.md),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildCardHeader('KTP', isValid),
|
||||||
|
const Divider(height: TSizes.spaceBtwItems),
|
||||||
|
|
||||||
|
// Display KTP details
|
||||||
|
if (model.nik.isNotEmpty)
|
||||||
|
_buildInfoRow('NIK', model.formattedNik),
|
||||||
|
if (model.name.isNotEmpty) _buildInfoRow('Name', model.name),
|
||||||
|
if (model.birthPlace.isNotEmpty)
|
||||||
|
_buildInfoRow('Birth Place', model.birthPlace),
|
||||||
|
if (model.birthDate.isNotEmpty)
|
||||||
|
_buildInfoRow('Birth Date', model.birthDate),
|
||||||
|
if (model.gender.isNotEmpty)
|
||||||
|
_buildInfoRow('Gender', model.gender),
|
||||||
|
if (model.address.isNotEmpty)
|
||||||
|
_buildInfoRow('Address', model.address),
|
||||||
|
|
||||||
|
if (!isValid) _buildDataWarning(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildKtaResultCard(KtaModel model, bool isValid) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: TSizes.spaceBtwItems),
|
||||||
|
child: Card(
|
||||||
|
elevation: 2,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
||||||
|
side: BorderSide(
|
||||||
|
color: isValid ? Colors.green : Colors.orange,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(TSizes.md),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildCardHeader('KTA', isValid),
|
||||||
|
const Divider(height: TSizes.spaceBtwItems),
|
||||||
|
|
||||||
|
// Display KTA details
|
||||||
|
if (model.name.isNotEmpty) _buildInfoRow('Name', model.name),
|
||||||
|
if (model.nrp.isNotEmpty)
|
||||||
|
_buildInfoRow('NRP', model.formattedNrp),
|
||||||
|
if (model.policeUnit.isNotEmpty)
|
||||||
|
_buildInfoRow('Unit', model.policeUnit),
|
||||||
|
|
||||||
|
// Get extra data
|
||||||
|
if (model.extraData != null) ...[
|
||||||
|
if (model.extraData!['pangkat'] != null)
|
||||||
|
_buildInfoRow('Rank', model.extraData!['pangkat']),
|
||||||
|
if (model.extraData!['tanggal_lahir'] != null)
|
||||||
|
_buildInfoRow(
|
||||||
|
'Birth Date',
|
||||||
|
model.extraData!['tanggal_lahir'],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
if (model.issueDate.isNotEmpty)
|
||||||
|
_buildInfoRow('Issue Date', model.issueDate),
|
||||||
|
if (model.cardNumber.isNotEmpty)
|
||||||
|
_buildInfoRow('Card Number', model.cardNumber),
|
||||||
|
|
||||||
|
if (!isValid) _buildDataWarning(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCardHeader(String cardType, bool isValid) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isValid ? Icons.check_circle : Icons.info,
|
||||||
|
color: isValid ? Colors.green : Colors.orange,
|
||||||
|
size: TSizes.iconMd,
|
||||||
|
),
|
||||||
|
const SizedBox(width: TSizes.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Extracted $cardType Information',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: TSizes.fontSizeMd,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoRow(String label, String value) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: TSizes.sm),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 100,
|
||||||
|
child: Text(
|
||||||
|
'$label:',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: TSizes.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(color: TColors.textPrimary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDataWarning() {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(top: TSizes.sm),
|
||||||
|
padding: const EdgeInsets.all(TSizes.sm),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusSm),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.warning_amber_rounded,
|
||||||
|
color: Colors.orange,
|
||||||
|
size: TSizes.iconSm,
|
||||||
|
),
|
||||||
|
const SizedBox(width: TSizes.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Some information might be missing or incorrect. Please verify the extracted data.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeXs,
|
||||||
|
color: Colors.orange.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,308 @@
|
||||||
|
import 'package:flutter/material.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/controllers/steps/identity_verification_controller.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/city_selection.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/form/date_picker_field.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/form/form_section_header.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/form/gender_selection.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/form/verification_status.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/verification/validation_message_card.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 formKey = GlobalKey<FormState>();
|
||||||
|
final controller = Get.find<IdentityVerificationController>();
|
||||||
|
final mainController = Get.find<FormRegistrationController>();
|
||||||
|
mainController.formKey = formKey;
|
||||||
|
|
||||||
|
return Form(
|
||||||
|
key: formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const FormSectionHeader(
|
||||||
|
title: 'Additional Information',
|
||||||
|
subtitle: 'Please provide additional personal details',
|
||||||
|
),
|
||||||
|
|
||||||
|
// Different fields based on role
|
||||||
|
if (!mainController.selectedRole.value!.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(
|
||||||
|
() => DatePickerField(
|
||||||
|
label: 'Birth Date',
|
||||||
|
controller: controller.birthDateController,
|
||||||
|
errorText: controller.birthDateError.value,
|
||||||
|
onDateSelected: (date) {
|
||||||
|
controller.birthDateError.value = '';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Gender selection
|
||||||
|
Obx(
|
||||||
|
() => GenderSelection(
|
||||||
|
selectedGender: controller.selectedGender.value,
|
||||||
|
onGenderChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
controller.selectedGender.value = value;
|
||||||
|
controller.genderError.value = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
errorText: controller.genderError.value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
() => VerificationStatus(
|
||||||
|
isVerifying: controller.isVerifying.value,
|
||||||
|
verifyingMessage: 'Processing your personal information...',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Verification Message
|
||||||
|
Obx(
|
||||||
|
() =>
|
||||||
|
controller.verificationMessage.value.isNotEmpty
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: TSizes.spaceBtwItems,
|
||||||
|
),
|
||||||
|
child: ValidationMessageCard(
|
||||||
|
message: controller.verificationMessage.value,
|
||||||
|
isValid: controller.isVerified.value,
|
||||||
|
hasConfirmed: false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: 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
|
||||||
|
const FormSectionHeader(
|
||||||
|
title: 'Face Verification',
|
||||||
|
subtitle: 'Verify that your face matches with your ID card photo',
|
||||||
|
),
|
||||||
|
|
||||||
|
// Face Verification Status
|
||||||
|
Obx(
|
||||||
|
() => VerificationStatus(
|
||||||
|
isVerifying: controller.isVerifyingFace.value,
|
||||||
|
verifyingMessage: 'Comparing face with ID photo...',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 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: ValidationMessageCard(
|
||||||
|
message: controller.faceVerificationMessage.value,
|
||||||
|
isValid: controller.isFaceVerified.value,
|
||||||
|
hasConfirmed: false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: 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 = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,271 @@
|
||||||
|
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/shared/widgets/form/form_section_header.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/form/verification_status.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/image_upload/image_source_dialog.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/image_upload/image_uploader.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/info/tips_container.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/verification/validation_message_card.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) {
|
||||||
|
// Initialize the form key
|
||||||
|
final formKey = GlobalKey<FormState>();
|
||||||
|
final controller = Get.find<ImageVerificationController>();
|
||||||
|
final mainController = Get.find<FormRegistrationController>();
|
||||||
|
mainController.formKey = formKey;
|
||||||
|
|
||||||
|
final isOfficer = mainController.selectedRole.value?.isOfficer ?? false;
|
||||||
|
final String idCardType = isOfficer ? 'KTA' : 'KTP';
|
||||||
|
|
||||||
|
return Form(
|
||||||
|
key: formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const FormSectionHeader(
|
||||||
|
title: 'Identity Document Verification',
|
||||||
|
subtitle: 'Please upload your identity documents for verification',
|
||||||
|
),
|
||||||
|
|
||||||
|
// ID Card Upload Section
|
||||||
|
_buildSectionHeader(
|
||||||
|
title: '$idCardType Upload',
|
||||||
|
subtitle: 'Upload a clear image of your $idCardType',
|
||||||
|
additionalText:
|
||||||
|
'Make sure all text and your photo are clearly visible',
|
||||||
|
),
|
||||||
|
|
||||||
|
// ID Card Upload Widget
|
||||||
|
Obx(
|
||||||
|
() => ImageUploader(
|
||||||
|
image: controller.idCardImage.value,
|
||||||
|
title: 'Upload $idCardType Image',
|
||||||
|
subtitle: 'Tap to select an image',
|
||||||
|
errorMessage: controller.idCardError.value,
|
||||||
|
isUploading: controller.isUploadingIdCard.value,
|
||||||
|
isVerifying: controller.isVerifying.value,
|
||||||
|
isConfirmed: controller.hasConfirmedIdCard.value,
|
||||||
|
onTapToSelect:
|
||||||
|
() => _showImageSourceDialog(controller, true, idCardType),
|
||||||
|
onClear: controller.clearIdCardImage,
|
||||||
|
onValidate: controller.validateIdCardImage,
|
||||||
|
placeholderIcon: Icons.add_a_photo,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ID Card Verification Status
|
||||||
|
Obx(
|
||||||
|
() => VerificationStatus(
|
||||||
|
isVerifying:
|
||||||
|
controller.isVerifying.value &&
|
||||||
|
!controller.isUploadingIdCard.value,
|
||||||
|
verifyingMessage: 'Validating your ID card...',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ID Card Verification Message
|
||||||
|
Obx(
|
||||||
|
() =>
|
||||||
|
controller.idCardValidationMessage.value.isNotEmpty
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: TSizes.spaceBtwItems,
|
||||||
|
),
|
||||||
|
child: ValidationMessageCard(
|
||||||
|
message: controller.idCardValidationMessage.value,
|
||||||
|
isValid: controller.isIdCardValid.value,
|
||||||
|
hasConfirmed: controller.hasConfirmedIdCard.value,
|
||||||
|
onConfirm: controller.confirmIdCardImage,
|
||||||
|
onTryAnother: controller.clearIdCardImage,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ID Card Tips
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
_buildIdCardTips(idCardType),
|
||||||
|
|
||||||
|
const SizedBox(height: TSizes.spaceBtwSections),
|
||||||
|
|
||||||
|
// Selfie Upload Section
|
||||||
|
_buildSectionHeader(
|
||||||
|
title: 'Selfie Upload',
|
||||||
|
subtitle: 'Take a clear selfie for identity verification',
|
||||||
|
additionalText:
|
||||||
|
'Make sure your face is well-lit and clearly visible',
|
||||||
|
),
|
||||||
|
|
||||||
|
// Selfie Upload Widget
|
||||||
|
Obx(
|
||||||
|
() => ImageUploader(
|
||||||
|
image: controller.selfieImage.value,
|
||||||
|
title: 'Take a Selfie',
|
||||||
|
subtitle: 'Tap to open camera',
|
||||||
|
errorMessage: controller.selfieError.value,
|
||||||
|
isUploading: controller.isUploadingSelfie.value,
|
||||||
|
isVerifying: controller.isVerifyingFace.value,
|
||||||
|
isConfirmed: controller.hasConfirmedSelfie.value,
|
||||||
|
onTapToSelect:
|
||||||
|
() => controller.pickSelfieImage(ImageSource.camera),
|
||||||
|
onClear: controller.clearSelfieImage,
|
||||||
|
onValidate: controller.validateSelfieImage,
|
||||||
|
placeholderIcon: Icons.face,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Selfie Verification Status
|
||||||
|
Obx(
|
||||||
|
() => VerificationStatus(
|
||||||
|
isVerifying:
|
||||||
|
controller.isVerifyingFace.value &&
|
||||||
|
!controller.isUploadingSelfie.value,
|
||||||
|
verifyingMessage: 'Validating your selfie...',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Selfie Verification Message
|
||||||
|
Obx(
|
||||||
|
() =>
|
||||||
|
controller.selfieValidationMessage.value.isNotEmpty
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: TSizes.spaceBtwItems,
|
||||||
|
),
|
||||||
|
child: ValidationMessageCard(
|
||||||
|
message: controller.selfieValidationMessage.value,
|
||||||
|
isValid: controller.isSelfieValid.value,
|
||||||
|
hasConfirmed: controller.hasConfirmedSelfie.value,
|
||||||
|
onConfirm: controller.confirmSelfieImage,
|
||||||
|
onTryAnother: controller.clearSelfieImage,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Selfie Tips
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
_buildSelfieTips(),
|
||||||
|
|
||||||
|
// 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 _buildSectionHeader({
|
||||||
|
required String title,
|
||||||
|
required String subtitle,
|
||||||
|
required String additionalText,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeMd,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: TColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
color: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.xs),
|
||||||
|
Text(
|
||||||
|
additionalText,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeXs,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
color: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildIdCardTips(String idCardType) {
|
||||||
|
return TipsContainer(
|
||||||
|
title: "Tips for a good $idCardType photo:",
|
||||||
|
tips: [
|
||||||
|
"Place the card on a dark, non-reflective surface",
|
||||||
|
"Ensure all four corners are visible",
|
||||||
|
"Make sure there's good lighting to avoid shadows",
|
||||||
|
"Your photo and all text should be clearly visible",
|
||||||
|
"Avoid using flash to prevent glare",
|
||||||
|
],
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
textColor: Colors.blue.shade800,
|
||||||
|
iconColor: Colors.blue,
|
||||||
|
borderColor: Colors.blue,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSelfieTips() {
|
||||||
|
return TipsContainer(
|
||||||
|
title: "Tips for a good selfie:",
|
||||||
|
tips: [
|
||||||
|
"Find a well-lit area with even lighting",
|
||||||
|
"Hold the camera at eye level",
|
||||||
|
"Look directly at the camera",
|
||||||
|
"Ensure your entire face is visible",
|
||||||
|
"Remove glasses and face coverings",
|
||||||
|
],
|
||||||
|
backgroundColor: TColors.primary,
|
||||||
|
textColor: TColors.primary,
|
||||||
|
iconColor: TColors.primary,
|
||||||
|
borderColor: TColors.primary,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showImageSourceDialog(
|
||||||
|
ImageVerificationController controller,
|
||||||
|
bool isIdCard,
|
||||||
|
String idCardType,
|
||||||
|
) {
|
||||||
|
ImageSourceDialog.show(
|
||||||
|
title: 'Select $idCardType Image Source',
|
||||||
|
message:
|
||||||
|
'Please ensure your ID card is clear, well-lit, and all text is readable',
|
||||||
|
onSourceSelected: controller.pickIdCardImage,
|
||||||
|
galleryOption: isIdCard, // Only allow gallery for ID card
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,9 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/officer_info_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/officer_info_controller.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/form/form_section_header.dart';
|
||||||
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
||||||
import 'package:sigap/src/utils/constants/colors.dart';
|
|
||||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
|
||||||
import 'package:sigap/src/utils/validators/validation.dart';
|
import 'package:sigap/src/utils/validators/validation.dart';
|
||||||
|
|
||||||
class OfficerInfoStep extends StatelessWidget {
|
class OfficerInfoStep extends StatelessWidget {
|
||||||
|
@ -11,30 +11,20 @@ class OfficerInfoStep extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final formKey = GlobalKey<FormState>();
|
||||||
final controller = Get.find<OfficerInfoController>();
|
final controller = Get.find<OfficerInfoController>();
|
||||||
|
final mainController = Get.find<FormRegistrationController>();
|
||||||
|
mainController.formKey = formKey;
|
||||||
|
|
||||||
return Form(
|
return Form(
|
||||||
key: controller.formKey,
|
key: formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
const FormSectionHeader(
|
||||||
'Officer Information',
|
title: 'Officer Information',
|
||||||
style: TextStyle(
|
subtitle: 'Please provide your officer details',
|
||||||
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
|
// NRP field
|
||||||
Obx(
|
Obx(
|
|
@ -1,9 +1,9 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/personal_info_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/personal_info_controller.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/form/form_section_header.dart';
|
||||||
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
||||||
import 'package:sigap/src/utils/constants/colors.dart';
|
|
||||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
|
||||||
import 'package:sigap/src/utils/validators/validation.dart';
|
import 'package:sigap/src/utils/validators/validation.dart';
|
||||||
|
|
||||||
class PersonalInfoStep extends StatelessWidget {
|
class PersonalInfoStep extends StatelessWidget {
|
||||||
|
@ -11,30 +11,20 @@ class PersonalInfoStep extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final formKey = GlobalKey<FormState>();
|
||||||
final controller = Get.find<PersonalInfoController>();
|
final controller = Get.find<PersonalInfoController>();
|
||||||
|
final mainController = Get.find<FormRegistrationController>();
|
||||||
|
mainController.formKey = formKey;
|
||||||
|
|
||||||
return Form(
|
return Form(
|
||||||
key: controller.formKey,
|
key: formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
const FormSectionHeader(
|
||||||
'Personal Information',
|
title: 'Personal Information',
|
||||||
style: TextStyle(
|
subtitle: 'Please provide your personal details',
|
||||||
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
|
// First Name field
|
||||||
Obx(
|
Obx(
|
|
@ -2,12 +2,12 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/id_card_verification_step.dart';
|
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/id_card_verification_step.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification_step.dart';
|
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/identity_verification_step.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/officer_info_step.dart';
|
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/officer_info_step.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/personal_info_step.dart';
|
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/personal_info_step.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/selfie_verification_step.dart';
|
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/selfie_verification_step.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/unit_info_step.dart';
|
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/unit_info_step.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
|
import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
|
||||||
import 'package:sigap/src/shared/widgets/indicators/step_indicator/step_indicator.dart';
|
import 'package:sigap/src/shared/widgets/indicators/step_indicator/step_indicator.dart';
|
||||||
import 'package:sigap/src/utils/constants/colors.dart';
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
|
@ -19,7 +19,6 @@ class FormRegistrationScreen extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Get the controller
|
|
||||||
final controller = Get.find<FormRegistrationController>();
|
final controller = Get.find<FormRegistrationController>();
|
||||||
final dark = THelperFunctions.isDarkMode(context);
|
final dark = THelperFunctions.isDarkMode(context);
|
||||||
|
|
||||||
|
@ -33,28 +32,9 @@ class FormRegistrationScreen extends StatelessWidget {
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: dark ? TColors.dark : TColors.light,
|
backgroundColor: dark ? TColors.dark : TColors.light,
|
||||||
appBar: AppBar(
|
appBar: _buildAppBar(context, dark),
|
||||||
automaticallyImplyLeading: false,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
elevation: 0,
|
|
||||||
title: Text(
|
|
||||||
'Complete Your Profile',
|
|
||||||
style: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
centerTitle: true,
|
|
||||||
leading: IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
Icons.arrow_back,
|
|
||||||
color: dark ? TColors.white : TColors.black,
|
|
||||||
size: TSizes.iconMd,
|
|
||||||
),
|
|
||||||
onPressed: () => Get.back(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: Obx(() {
|
body: Obx(() {
|
||||||
// Make loading check more robust - showing a loading state while controller initializes
|
// Show loading state while controller initializes
|
||||||
if (controller.userMetadata.value.userId == null &&
|
if (controller.userMetadata.value.userId == null &&
|
||||||
controller.userMetadata.value.roleId == null) {
|
controller.userMetadata.value.roleId == null) {
|
||||||
return const Center(
|
return const Center(
|
||||||
|
@ -73,18 +53,7 @@ class FormRegistrationScreen extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Step indicator
|
// Step indicator
|
||||||
Padding(
|
_buildStepIndicator(controller),
|
||||||
padding: const EdgeInsets.all(TSizes.defaultSpace),
|
|
||||||
child: Obx(
|
|
||||||
() => StepIndicator(
|
|
||||||
currentStep: controller.currentStep.value,
|
|
||||||
totalSteps: controller.totalSteps,
|
|
||||||
stepTitles: controller.getStepTitles(),
|
|
||||||
onStepTapped: controller.goToStep,
|
|
||||||
style: StepIndicatorStyle.standard,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Step content
|
// Step content
|
||||||
Expanded(
|
Expanded(
|
||||||
|
@ -99,54 +68,7 @@ class FormRegistrationScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
|
|
||||||
// Navigation buttons
|
// Navigation buttons
|
||||||
Padding(
|
_buildNavigationButtons(controller),
|
||||||
padding: const EdgeInsets.all(TSizes.defaultSpace),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
// Back button
|
|
||||||
Obx(
|
|
||||||
() =>
|
|
||||||
controller.currentStep.value > 0
|
|
||||||
? Expanded(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(
|
|
||||||
right: TSizes.sm,
|
|
||||||
),
|
|
||||||
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
|
|
||||||
? TSizes.sm
|
|
||||||
: 0.0,
|
|
||||||
),
|
|
||||||
child: Obx(
|
|
||||||
() => AuthButton(
|
|
||||||
text:
|
|
||||||
controller.currentStep.value ==
|
|
||||||
controller.totalSteps - 1
|
|
||||||
? 'Submit'
|
|
||||||
: 'Next',
|
|
||||||
onPressed: controller.nextStep,
|
|
||||||
isLoading: controller.isLoading.value,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -154,6 +76,89 @@ class FormRegistrationScreen extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppBar _buildAppBar(BuildContext context, bool dark) {
|
||||||
|
return AppBar(
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
title: Text(
|
||||||
|
'Complete Your Profile',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
centerTitle: true,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.arrow_back,
|
||||||
|
color: dark ? TColors.white : TColors.black,
|
||||||
|
size: TSizes.iconMd,
|
||||||
|
),
|
||||||
|
onPressed: () => Get.back(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStepIndicator(FormRegistrationController controller) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(TSizes.defaultSpace),
|
||||||
|
child: Obx(
|
||||||
|
() => StepIndicator(
|
||||||
|
currentStep: controller.currentStep.value,
|
||||||
|
totalSteps: controller.totalSteps,
|
||||||
|
stepTitles: controller.getStepTitles(),
|
||||||
|
onStepTapped: controller.goToStep,
|
||||||
|
style: StepIndicatorStyle.standard,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNavigationButtons(FormRegistrationController controller) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(TSizes.defaultSpace),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Back button
|
||||||
|
Obx(
|
||||||
|
() =>
|
||||||
|
controller.currentStep.value > 0
|
||||||
|
? Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(right: TSizes.sm),
|
||||||
|
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 ? TSizes.sm : 0.0,
|
||||||
|
),
|
||||||
|
child: Obx(
|
||||||
|
() => AuthButton(
|
||||||
|
text:
|
||||||
|
controller.currentStep.value == controller.totalSteps - 1
|
||||||
|
? 'Submit'
|
||||||
|
: 'Next',
|
||||||
|
onPressed: controller.nextStep,
|
||||||
|
isLoading: controller.isLoading.value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildStepContent(FormRegistrationController controller) {
|
Widget _buildStepContent(FormRegistrationController controller) {
|
||||||
final isOfficer = controller.selectedRole.value?.isOfficer ?? false;
|
final isOfficer = controller.selectedRole.value?.isOfficer ?? false;
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,165 @@
|
||||||
|
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/selfie_verification_controller.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/image_upload/image_uploader.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/info/tips_container.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/verification/validation_message_card.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) {
|
||||||
|
// Initialize form key
|
||||||
|
final formKey = GlobalKey<FormState>();
|
||||||
|
final controller = Get.find<SelfieVerificationController>();
|
||||||
|
final mainController = Get.find<FormRegistrationController>();
|
||||||
|
mainController.formKey = formKey;
|
||||||
|
|
||||||
|
return Form(
|
||||||
|
key: formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildHeader(context),
|
||||||
|
|
||||||
|
// Selfie Upload Widget
|
||||||
|
Obx(
|
||||||
|
() => ImageUploader(
|
||||||
|
image: controller.selfieImage.value,
|
||||||
|
title: 'Take a Selfie',
|
||||||
|
subtitle: 'Tap to take a selfie (max 4MB)',
|
||||||
|
errorMessage: controller.selfieError.value,
|
||||||
|
isUploading: controller.isUploadingSelfie.value,
|
||||||
|
isVerifying: controller.isVerifyingFace.value,
|
||||||
|
isConfirmed: controller.hasConfirmedSelfie.value,
|
||||||
|
onTapToSelect: () => _captureSelfie(controller),
|
||||||
|
onClear: controller.clearSelfieImage,
|
||||||
|
onValidate: controller.validateSelfieImage,
|
||||||
|
placeholderIcon: Icons.face,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 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: ValidationMessageCard(
|
||||||
|
message: controller.selfieValidationMessage.value,
|
||||||
|
isValid: controller.isSelfieValid.value,
|
||||||
|
hasConfirmed: controller.hasConfirmedSelfie.value,
|
||||||
|
onConfirm: controller.confirmSelfieImage,
|
||||||
|
onTryAnother: controller.clearSelfieImage,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: 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
|
||||||
|
_buildSelfieTips(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader(BuildContext context) {
|
||||||
|
return 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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSelfieTips() {
|
||||||
|
return TipsContainer(
|
||||||
|
title: 'Tips for a Good Selfie:',
|
||||||
|
tips: [
|
||||||
|
'Find a well-lit area with even lighting',
|
||||||
|
'Hold the camera at eye level',
|
||||||
|
'Look directly at the camera',
|
||||||
|
'Ensure your entire face is visible',
|
||||||
|
'Remove glasses and face coverings',
|
||||||
|
],
|
||||||
|
backgroundColor: TColors.primary,
|
||||||
|
textColor: TColors.primary,
|
||||||
|
iconColor: TColors.primary,
|
||||||
|
borderColor: TColors.primary,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _captureSelfie(SelfieVerificationController controller) {
|
||||||
|
controller.pickSelfieImage(ImageSource.camera);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,10 +1,10 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/unit_info_controller.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/dropdown/custom_dropdown.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/form/form_section_header.dart';
|
||||||
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
||||||
import 'package:sigap/src/utils/constants/colors.dart';
|
|
||||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
|
||||||
import 'package:sigap/src/utils/validators/validation.dart';
|
import 'package:sigap/src/utils/validators/validation.dart';
|
||||||
|
|
||||||
class UnitInfoStep extends StatelessWidget {
|
class UnitInfoStep extends StatelessWidget {
|
||||||
|
@ -12,30 +12,20 @@ class UnitInfoStep extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final formKey = GlobalKey<FormState>();
|
||||||
final controller = Get.find<UnitInfoController>();
|
final controller = Get.find<UnitInfoController>();
|
||||||
|
final mainController = Get.find<FormRegistrationController>();
|
||||||
|
mainController.formKey = formKey;
|
||||||
|
|
||||||
return Form(
|
return Form(
|
||||||
key: controller.formKey,
|
key: formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
const FormSectionHeader(
|
||||||
'Unit Information',
|
title: 'Unit Information',
|
||||||
style: TextStyle(
|
subtitle: 'Please provide your unit details',
|
||||||
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
|
// Position field
|
||||||
Obx(
|
Obx(
|
|
@ -1,554 +0,0 @@
|
||||||
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),
|
|
||||||
|
|
||||||
// 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(),
|
|
||||||
),
|
|
||||||
|
|
||||||
// ID Card Upload Widget
|
|
||||||
_buildIdCardUploader(controller, isOfficer),
|
|
||||||
|
|
||||||
// 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(),
|
|
||||||
),
|
|
||||||
|
|
||||||
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
|
||||||
border: Border.all(
|
|
||||||
color: borderColor,
|
|
||||||
width:
|
|
||||||
controller.idCardError.value.isNotEmpty
|
|
||||||
? 2
|
|
||||||
: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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 (max 4MB)',
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
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(
|
|
||||||
controller.idCardError.value,
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Show file size information if image is uploaded
|
|
||||||
if (controller.idCardImage.value != null)
|
|
||||||
FutureBuilder<int>(
|
|
||||||
future: File(controller.idCardImage.value!.path).length(),
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (snapshot.hasData) {
|
|
||||||
final fileSizeKB = snapshot.data! / 1024;
|
|
||||||
final fileSizeMB = fileSizeKB / 1024;
|
|
||||||
final isOversized =
|
|
||||||
snapshot.data! > controller.maxFileSizeBytes;
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: TSizes.sm),
|
|
||||||
child: Text(
|
|
||||||
'File size: ${fileSizeMB.toStringAsFixed(2)} MB',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: TSizes.fontSizeSm,
|
|
||||||
color:
|
|
||||||
isOversized
|
|
||||||
? TColors.error
|
|
||||||
: TColors.textSecondary,
|
|
||||||
fontWeight:
|
|
||||||
isOversized
|
|
||||||
? FontWeight.bold
|
|
||||||
: FontWeight.normal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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. Maximum file size: 4MB';
|
|
||||||
|
|
||||||
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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,587 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,856 +0,0 @@
|
||||||
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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,561 +0,0 @@
|
||||||
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 take a selfie (max 4MB)',
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
// File size information
|
|
||||||
if (controller.selfieImage.value != null)
|
|
||||||
FutureBuilder<int>(
|
|
||||||
future: File(controller.selfieImage.value!.path).length(),
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (snapshot.hasData) {
|
|
||||||
final fileSizeKB = snapshot.data! / 1024;
|
|
||||||
final fileSizeMB = fileSizeKB / 1024;
|
|
||||||
final isOversized =
|
|
||||||
snapshot.data! > controller.maxFileSizeBytes;
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: TSizes.sm),
|
|
||||||
child: Text(
|
|
||||||
'File size: ${fileSizeMB.toStringAsFixed(2)} MB',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: TSizes.fontSizeSm,
|
|
||||||
color:
|
|
||||||
isOversized
|
|
||||||
? TColors.error
|
|
||||||
: TColors.textSecondary,
|
|
||||||
fontWeight:
|
|
||||||
isOversized
|
|
||||||
? FontWeight.bold
|
|
||||||
: FontWeight.normal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _captureSelfie(SelfieVerificationController controller) {
|
|
||||||
controller.pickSelfieImage(ImageSource.camera);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -17,6 +17,9 @@ class SignInScreen extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// Init form key
|
||||||
|
final formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
// Get the controller
|
// Get the controller
|
||||||
final controller = Get.find<SignInController>();
|
final controller = Get.find<SignInController>();
|
||||||
|
|
||||||
|
@ -35,7 +38,7 @@ class SignInScreen extends StatelessWidget {
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24.0),
|
padding: const EdgeInsets.all(24.0),
|
||||||
child: Form(
|
child: Form(
|
||||||
key: controller.signinFormKey,
|
key: formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
@ -92,7 +95,7 @@ class SignInScreen extends StatelessWidget {
|
||||||
Obx(
|
Obx(
|
||||||
() => AuthButton(
|
() => AuthButton(
|
||||||
text: 'Sign In',
|
text: 'Sign In',
|
||||||
onPressed: controller.credentialsSignIn,
|
onPressed: () => controller.credentialsSignIn(formKey),
|
||||||
isLoading: controller.isLoading.value,
|
isLoading: controller.isLoading.value,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -112,7 +115,7 @@ class SignInScreen extends StatelessWidget {
|
||||||
color: TColors.light,
|
color: TColors.light,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
onPressed: () => controller.googleSignIn(),
|
onPressed: () => controller.googleSignIn(formKey),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
|
@ -0,0 +1,146 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// Model representing a KTA (Kartu Tanda Anggota) police officer ID card
|
||||||
|
class KtaModel extends Equatable {
|
||||||
|
/// Full name of the officer
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
/// NRP (Nomor Registrasi Polisi) - Police Registration Number
|
||||||
|
final String nrp;
|
||||||
|
|
||||||
|
/// Police station/unit where the officer is assigned
|
||||||
|
final String policeUnit;
|
||||||
|
|
||||||
|
/// Issue date of the ID card in format dd-mm-yyyy
|
||||||
|
final String issueDate;
|
||||||
|
|
||||||
|
/// Card unique identification number
|
||||||
|
final String cardNumber;
|
||||||
|
|
||||||
|
/// URL to the officer's photo on the card
|
||||||
|
final String? photoUrl;
|
||||||
|
|
||||||
|
/// Additional details about the card or officer
|
||||||
|
final Map<String, dynamic>? extraData;
|
||||||
|
|
||||||
|
const KtaModel({
|
||||||
|
required this.name,
|
||||||
|
required this.nrp,
|
||||||
|
required this.policeUnit,
|
||||||
|
required this.issueDate,
|
||||||
|
required this.cardNumber,
|
||||||
|
this.photoUrl,
|
||||||
|
this.extraData,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Create a KTA model from a map/JSON
|
||||||
|
factory KtaModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return KtaModel(
|
||||||
|
name: json['name'] ?? '',
|
||||||
|
nrp: json['nrp'] ?? '',
|
||||||
|
policeUnit: json['police_unit'] ?? '',
|
||||||
|
issueDate: json['issue_date'] ?? '',
|
||||||
|
cardNumber: json['card_number'] ?? '',
|
||||||
|
photoUrl: json['photo_url'],
|
||||||
|
extraData: json['extra_data'] as Map<String, dynamic>?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert the KTA model to a map/JSON
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'nrp': nrp,
|
||||||
|
'police_unit': policeUnit,
|
||||||
|
'issue_date': issueDate,
|
||||||
|
'card_number': cardNumber,
|
||||||
|
'photo_url': photoUrl,
|
||||||
|
'extra_data': extraData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an empty KTA model
|
||||||
|
factory KtaModel.empty() {
|
||||||
|
return const KtaModel(
|
||||||
|
name: '',
|
||||||
|
nrp: '',
|
||||||
|
policeUnit: '',
|
||||||
|
issueDate: '',
|
||||||
|
cardNumber: '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format the NRP with proper spacing for display
|
||||||
|
/// e.g., "1234567890" becomes "12345 67890"
|
||||||
|
String get formattedNrp {
|
||||||
|
if (nrp.length < 5) return nrp;
|
||||||
|
return '${nrp.substring(0, 5)} ${nrp.substring(5)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the KTA model contains valid and complete information
|
||||||
|
bool get isValid {
|
||||||
|
return name.isNotEmpty &&
|
||||||
|
nrp.isNotEmpty &&
|
||||||
|
policeUnit.isNotEmpty &&
|
||||||
|
cardNumber.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a copy of this KTA model with modified fields
|
||||||
|
KtaModel copyWith({
|
||||||
|
String? name,
|
||||||
|
String? nrp,
|
||||||
|
String? policeUnit,
|
||||||
|
String? issueDate,
|
||||||
|
String? cardNumber,
|
||||||
|
String? photoUrl,
|
||||||
|
Map<String, dynamic>? extraData,
|
||||||
|
}) {
|
||||||
|
return KtaModel(
|
||||||
|
name: name ?? this.name,
|
||||||
|
nrp: nrp ?? this.nrp,
|
||||||
|
policeUnit: policeUnit ?? this.policeUnit,
|
||||||
|
issueDate: issueDate ?? this.issueDate,
|
||||||
|
cardNumber: cardNumber ?? this.cardNumber,
|
||||||
|
photoUrl: photoUrl ?? this.photoUrl,
|
||||||
|
extraData: extraData ?? this.extraData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the issue date into a DateTime object
|
||||||
|
/// Expects format: DD-MM-YYYY
|
||||||
|
DateTime? get parsedIssueDate {
|
||||||
|
try {
|
||||||
|
if (issueDate.isEmpty) return null;
|
||||||
|
|
||||||
|
// Split the date string by '-'
|
||||||
|
final parts = issueDate.split('-');
|
||||||
|
if (parts.length != 3) return null;
|
||||||
|
|
||||||
|
final day = int.tryParse(parts[0]);
|
||||||
|
final month = int.tryParse(parts[1]);
|
||||||
|
final year = int.tryParse(parts[2]);
|
||||||
|
|
||||||
|
if (day == null || month == null || year == null) return null;
|
||||||
|
|
||||||
|
return DateTime(year, month, day);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
name,
|
||||||
|
nrp,
|
||||||
|
policeUnit,
|
||||||
|
issueDate,
|
||||||
|
cardNumber,
|
||||||
|
photoUrl,
|
||||||
|
extraData,
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'KtaModel(name: $name, nrp: $nrp, policeUnit: $policeUnit, issueDate: $issueDate, cardNumber: $cardNumber)';
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:get_storage/get_storage.dart';
|
import 'package:get_storage/get_storage.dart';
|
||||||
|
import 'package:logger/logger.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';
|
||||||
|
@ -14,7 +15,7 @@ class OnboardingController extends GetxController
|
||||||
static OnboardingController get instance => Get.find();
|
static OnboardingController get instance => Get.find();
|
||||||
|
|
||||||
// Storage for onboarding state
|
// Storage for onboarding state
|
||||||
final _storage = GetStorage();
|
final storage = GetStorage();
|
||||||
|
|
||||||
// Location service
|
// Location service
|
||||||
final _locationService = Get.find<LocationService>();
|
final _locationService = Get.find<LocationService>();
|
||||||
|
@ -70,7 +71,7 @@ class OnboardingController extends GetxController
|
||||||
// Method to go to next page
|
// Method to go to next page
|
||||||
void nextPage() {
|
void nextPage() {
|
||||||
if (currentIndex.value == contents.length - 1) {
|
if (currentIndex.value == contents.length - 1) {
|
||||||
navigateToWelcomeScreen();
|
skipOnboarding();
|
||||||
} else {
|
} else {
|
||||||
pageController.nextPage(
|
pageController.nextPage(
|
||||||
duration: const Duration(milliseconds: 500),
|
duration: const Duration(milliseconds: 500),
|
||||||
|
@ -81,13 +82,13 @@ class OnboardingController extends GetxController
|
||||||
|
|
||||||
// Method to skip to welcome screen
|
// Method to skip to welcome screen
|
||||||
void skipToWelcomeScreen() {
|
void skipToWelcomeScreen() {
|
||||||
navigateToWelcomeScreen();
|
skipOnboarding();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method to navigate to welcome screen
|
// Method to navigate to welcome screen
|
||||||
void navigateToWelcomeScreen() {
|
void skipOnboarding() {
|
||||||
// Mark onboarding as completed in storage
|
// Mark onboarding as completed in storage
|
||||||
_storage.write('ONBOARDING_COMPLETED', true);
|
storage.write('isFirstTime', true);
|
||||||
Get.offAllNamed(AppRoutes.welcome);
|
Get.offAllNamed(AppRoutes.welcome);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -108,8 +109,15 @@ class OnboardingController extends GetxController
|
||||||
|
|
||||||
TFullScreenLoader.stopLoading();
|
TFullScreenLoader.stopLoading();
|
||||||
|
|
||||||
|
Logger().i('isFirstTime before: ${storage.read('isFirstTime')}');
|
||||||
|
|
||||||
|
storage.write('isFirstTime', false);
|
||||||
|
|
||||||
|
Logger().i('isFirstTime after: ${storage.read('isFirstTime')}');
|
||||||
|
|
||||||
if (isLocationValid) {
|
if (isLocationValid) {
|
||||||
// If location is valid, proceed to role selection
|
// If location is valid, proceed to role selection
|
||||||
|
|
||||||
Get.offAllNamed(AppRoutes.signupWithRole);
|
Get.offAllNamed(AppRoutes.signupWithRole);
|
||||||
|
|
||||||
// TLoaders.successSnackBar(
|
// TLoaders.successSnackBar(
|
||||||
|
@ -118,7 +126,6 @@ class OnboardingController extends GetxController
|
||||||
// );
|
// );
|
||||||
|
|
||||||
// Store isfirstTime to false in storage
|
// Store isfirstTime to false in storage
|
||||||
_storage.write('isFirstTime', false);
|
|
||||||
} else {
|
} else {
|
||||||
// If location is invalid, show warning screen
|
// If location is invalid, show warning screen
|
||||||
Get.offAllNamed(AppRoutes.locationWarning);
|
Get.offAllNamed(AppRoutes.locationWarning);
|
||||||
|
@ -141,6 +148,8 @@ class OnboardingController extends GetxController
|
||||||
}
|
}
|
||||||
|
|
||||||
void goToSignIn() {
|
void goToSignIn() {
|
||||||
|
storage.write('isFirstTime', true);
|
||||||
|
|
||||||
Get.offAllNamed(AppRoutes.signIn);
|
Get.offAllNamed(AppRoutes.signIn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,7 +31,7 @@ class OnboardingScreen extends StatelessWidget {
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(TSizes.md),
|
padding: const EdgeInsets.all(TSizes.md),
|
||||||
child: TextButton(
|
child: TextButton(
|
||||||
onPressed: controller.skipToWelcomeScreen,
|
onPressed: controller.skipOnboarding,
|
||||||
child: Text(
|
child: Text(
|
||||||
'Skip',
|
'Skip',
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
|
|
@ -0,0 +1,257 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// Model representing a KTP (Kartu Tanda Penduduk) Indonesian ID card
|
||||||
|
class KtpModel extends Equatable {
|
||||||
|
/// NIK (Nomor Induk Kependudukan) - National Identity Number
|
||||||
|
final String nik;
|
||||||
|
|
||||||
|
/// Full name as shown on the KTP
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
/// Place of birth
|
||||||
|
final String birthPlace;
|
||||||
|
|
||||||
|
/// Date of birth in format dd-mm-yyyy
|
||||||
|
final String birthDate;
|
||||||
|
|
||||||
|
/// Gender (Male/Female)
|
||||||
|
final String gender;
|
||||||
|
|
||||||
|
/// Blood type
|
||||||
|
final String? bloodType;
|
||||||
|
|
||||||
|
/// Full address
|
||||||
|
final String address;
|
||||||
|
|
||||||
|
/// RT/RW (Neighborhood units)
|
||||||
|
final String? rtRw;
|
||||||
|
|
||||||
|
/// Kelurahan/Desa (Urban/Rural village)
|
||||||
|
final String? kelurahan;
|
||||||
|
|
||||||
|
/// Kecamatan (District)
|
||||||
|
final String? kecamatan;
|
||||||
|
|
||||||
|
/// Religion
|
||||||
|
final String? religion;
|
||||||
|
|
||||||
|
/// Marital status
|
||||||
|
final String? maritalStatus;
|
||||||
|
|
||||||
|
/// Occupation
|
||||||
|
final String? occupation;
|
||||||
|
|
||||||
|
/// Nationality
|
||||||
|
final String nationality;
|
||||||
|
|
||||||
|
/// URL to the person's photo on the card
|
||||||
|
final String? photoUrl;
|
||||||
|
|
||||||
|
/// Issue date of the KTP
|
||||||
|
final String? issueDate;
|
||||||
|
|
||||||
|
/// Additional details extracted from the KTP
|
||||||
|
final Map<String, dynamic>? extraData;
|
||||||
|
|
||||||
|
const KtpModel({
|
||||||
|
required this.nik,
|
||||||
|
required this.name,
|
||||||
|
required this.birthPlace,
|
||||||
|
required this.birthDate,
|
||||||
|
required this.gender,
|
||||||
|
required this.address,
|
||||||
|
required this.nationality,
|
||||||
|
this.bloodType,
|
||||||
|
this.rtRw,
|
||||||
|
this.kelurahan,
|
||||||
|
this.kecamatan,
|
||||||
|
this.religion,
|
||||||
|
this.maritalStatus,
|
||||||
|
this.occupation,
|
||||||
|
this.photoUrl,
|
||||||
|
this.issueDate,
|
||||||
|
this.extraData,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Create a KTP model from a map/JSON
|
||||||
|
factory KtpModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return KtpModel(
|
||||||
|
nik: json['nik'] ?? '',
|
||||||
|
name: json['name'] ?? '',
|
||||||
|
birthPlace: json['birth_place'] ?? '',
|
||||||
|
birthDate: json['birth_date'] ?? '',
|
||||||
|
gender: json['gender'] ?? '',
|
||||||
|
address: json['address'] ?? '',
|
||||||
|
nationality: json['nationality'] ?? 'WNI',
|
||||||
|
bloodType: json['blood_type'],
|
||||||
|
rtRw: json['rt_rw'],
|
||||||
|
kelurahan: json['kelurahan'],
|
||||||
|
kecamatan: json['kecamatan'],
|
||||||
|
religion: json['religion'],
|
||||||
|
maritalStatus: json['marital_status'],
|
||||||
|
occupation: json['occupation'],
|
||||||
|
photoUrl: json['photo_url'],
|
||||||
|
issueDate: json['issue_date'],
|
||||||
|
extraData: json['extra_data'] as Map<String, dynamic>?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert the KTP model to a map/JSON
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'nik': nik,
|
||||||
|
'name': name,
|
||||||
|
'birth_place': birthPlace,
|
||||||
|
'birth_date': birthDate,
|
||||||
|
'gender': gender,
|
||||||
|
'address': address,
|
||||||
|
'nationality': nationality,
|
||||||
|
'blood_type': bloodType,
|
||||||
|
'rt_rw': rtRw,
|
||||||
|
'kelurahan': kelurahan,
|
||||||
|
'kecamatan': kecamatan,
|
||||||
|
'religion': religion,
|
||||||
|
'marital_status': maritalStatus,
|
||||||
|
'occupation': occupation,
|
||||||
|
'photo_url': photoUrl,
|
||||||
|
'issue_date': issueDate,
|
||||||
|
'extra_data': extraData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an empty KTP model
|
||||||
|
factory KtpModel.empty() {
|
||||||
|
return const KtpModel(
|
||||||
|
nik: '',
|
||||||
|
name: '',
|
||||||
|
birthPlace: '',
|
||||||
|
birthDate: '',
|
||||||
|
gender: '',
|
||||||
|
address: '',
|
||||||
|
nationality: 'WNI',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the birth date into a DateTime object
|
||||||
|
/// Expects format: DD-MM-YYYY
|
||||||
|
DateTime? get parsedBirthDate {
|
||||||
|
try {
|
||||||
|
if (birthDate.isEmpty) return null;
|
||||||
|
|
||||||
|
// Split the date string by '-'
|
||||||
|
final parts = birthDate.split('-');
|
||||||
|
if (parts.length != 3) return null;
|
||||||
|
|
||||||
|
final day = int.tryParse(parts[0]);
|
||||||
|
final month = int.tryParse(parts[1]);
|
||||||
|
final year = int.tryParse(parts[2]);
|
||||||
|
|
||||||
|
if (day == null || month == null || year == null) return null;
|
||||||
|
|
||||||
|
return DateTime(year, month, day);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format the NIK with proper spacing for display
|
||||||
|
/// e.g., "1234567890123456" becomes "1234 5678 9012 3456"
|
||||||
|
String get formattedNik {
|
||||||
|
if (nik.length != 16) return nik;
|
||||||
|
|
||||||
|
return '${nik.substring(0, 4)} ${nik.substring(4, 8)} ${nik.substring(8, 12)} ${nik.substring(12)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the person's age based on birth date
|
||||||
|
int? get age {
|
||||||
|
final birthDateTime = parsedBirthDate;
|
||||||
|
if (birthDateTime == null) return null;
|
||||||
|
|
||||||
|
final today = DateTime.now();
|
||||||
|
int age = today.year - birthDateTime.year;
|
||||||
|
|
||||||
|
// Adjust age if birthday hasn't occurred yet this year
|
||||||
|
if (today.month < birthDateTime.month ||
|
||||||
|
(today.month == birthDateTime.month && today.day < birthDateTime.day)) {
|
||||||
|
age--;
|
||||||
|
}
|
||||||
|
|
||||||
|
return age;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the KTP model contains valid and complete information
|
||||||
|
bool get isValid {
|
||||||
|
return nik.length == 16 &&
|
||||||
|
name.isNotEmpty &&
|
||||||
|
birthPlace.isNotEmpty &&
|
||||||
|
birthDate.isNotEmpty &&
|
||||||
|
gender.isNotEmpty &&
|
||||||
|
address.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a copy of this KTP model with modified fields
|
||||||
|
KtpModel copyWith({
|
||||||
|
String? nik,
|
||||||
|
String? name,
|
||||||
|
String? birthPlace,
|
||||||
|
String? birthDate,
|
||||||
|
String? gender,
|
||||||
|
String? bloodType,
|
||||||
|
String? address,
|
||||||
|
String? rtRw,
|
||||||
|
String? kelurahan,
|
||||||
|
String? kecamatan,
|
||||||
|
String? religion,
|
||||||
|
String? maritalStatus,
|
||||||
|
String? occupation,
|
||||||
|
String? nationality,
|
||||||
|
String? photoUrl,
|
||||||
|
String? issueDate,
|
||||||
|
Map<String, dynamic>? extraData,
|
||||||
|
}) {
|
||||||
|
return KtpModel(
|
||||||
|
nik: nik ?? this.nik,
|
||||||
|
name: name ?? this.name,
|
||||||
|
birthPlace: birthPlace ?? this.birthPlace,
|
||||||
|
birthDate: birthDate ?? this.birthDate,
|
||||||
|
gender: gender ?? this.gender,
|
||||||
|
bloodType: bloodType ?? this.bloodType,
|
||||||
|
address: address ?? this.address,
|
||||||
|
rtRw: rtRw ?? this.rtRw,
|
||||||
|
kelurahan: kelurahan ?? this.kelurahan,
|
||||||
|
kecamatan: kecamatan ?? this.kecamatan,
|
||||||
|
religion: religion ?? this.religion,
|
||||||
|
maritalStatus: maritalStatus ?? this.maritalStatus,
|
||||||
|
occupation: occupation ?? this.occupation,
|
||||||
|
nationality: nationality ?? this.nationality,
|
||||||
|
photoUrl: photoUrl ?? this.photoUrl,
|
||||||
|
issueDate: issueDate ?? this.issueDate,
|
||||||
|
extraData: extraData ?? this.extraData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
nik,
|
||||||
|
name,
|
||||||
|
birthPlace,
|
||||||
|
birthDate,
|
||||||
|
gender,
|
||||||
|
bloodType,
|
||||||
|
address,
|
||||||
|
rtRw,
|
||||||
|
kelurahan,
|
||||||
|
kecamatan,
|
||||||
|
religion,
|
||||||
|
maritalStatus,
|
||||||
|
occupation,
|
||||||
|
nationality,
|
||||||
|
photoUrl,
|
||||||
|
issueDate,
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'KtpModel(nik: $nik, name: $name, birthPlace: $birthPlace, birthDate: $birthDate, gender: $gender)';
|
||||||
|
}
|
||||||
|
}
|
|
@ -243,6 +243,47 @@ class UserRepository extends GetxController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get user by email
|
||||||
|
Future<UserModel?> getUserByEmail(String email) async {
|
||||||
|
try {
|
||||||
|
final userData =
|
||||||
|
await _supabase
|
||||||
|
.from('users')
|
||||||
|
.select('*, profiles(*), role:roles(*)')
|
||||||
|
.eq('email', email)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
return UserModel.fromJson(userData);
|
||||||
|
} on PostgrestException catch (error) {
|
||||||
|
_logger.e('PostgrestException in getUserByEmail: ${error.message}');
|
||||||
|
throw TExceptions.fromCode(error.code ?? 'unknown-error');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.e('Exception in getUserByEmail: $e');
|
||||||
|
return null; // Return null if user not found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chekch if email is already in use
|
||||||
|
Future<bool> isEmailInUse(String email) async {
|
||||||
|
try {
|
||||||
|
final userData =
|
||||||
|
await _supabase
|
||||||
|
.from('users')
|
||||||
|
.select('id')
|
||||||
|
.eq('email', email)
|
||||||
|
.maybeSingle(); // ⬅️ Ganti single() dengan maybeSingle()
|
||||||
|
|
||||||
|
return userData != null;
|
||||||
|
} on PostgrestException catch (error) {
|
||||||
|
_logger.e('PostgrestException in isEmailInUse: ${error.message}');
|
||||||
|
throw TExceptions.fromCode(error.code ?? 'unknown-error');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.e('Exception in isEmailInUse: $e');
|
||||||
|
return false; // Default: anggap belum digunakan jika gagal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Search users by name/username/email
|
// Search users by name/username/email
|
||||||
Future<List<UserModel>> searchUsers(String query, {int limit = 20}) async {
|
Future<List<UserModel>> searchUsers(String query, {int limit = 20}) async {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -0,0 +1,120 @@
|
||||||
|
import 'package:calendar_date_picker2/calendar_date_picker2.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
|
class DatePickerField extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String? errorText;
|
||||||
|
final Function(DateTime) onDateSelected;
|
||||||
|
final DateTime? firstDate;
|
||||||
|
final DateTime? lastDate;
|
||||||
|
final String dateFormat;
|
||||||
|
|
||||||
|
const DatePickerField({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.controller,
|
||||||
|
this.errorText,
|
||||||
|
required this.onDateSelected,
|
||||||
|
this.firstDate,
|
||||||
|
this.lastDate,
|
||||||
|
this.dateFormat = 'yyyy-MM-dd',
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label, style: Theme.of(context).textTheme.bodyMedium),
|
||||||
|
const SizedBox(height: TSizes.xs),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _showDatePicker(context),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: TSizes.md,
|
||||||
|
vertical: TSizes.md,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color:
|
||||||
|
errorText != null && errorText!.isNotEmpty
|
||||||
|
? TColors.error
|
||||||
|
: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusLg),
|
||||||
|
color: Colors.transparent,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
controller.text.isEmpty ? 'Select $label' : controller.text,
|
||||||
|
style: TextStyle(
|
||||||
|
color:
|
||||||
|
controller.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 (errorText != null && errorText!.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: TSizes.xs, left: TSizes.xs),
|
||||||
|
child: Text(
|
||||||
|
errorText!,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: TColors.error,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showDatePicker(BuildContext context) async {
|
||||||
|
final results = await showCalendarDatePicker2Dialog(
|
||||||
|
context: context,
|
||||||
|
config: CalendarDatePicker2WithActionButtonsConfig(
|
||||||
|
calendarType: CalendarDatePicker2Type.single,
|
||||||
|
selectedDayHighlightColor: TColors.primary,
|
||||||
|
lastDate: lastDate ?? DateTime.now(),
|
||||||
|
firstDate: firstDate ?? DateTime(1900),
|
||||||
|
),
|
||||||
|
dialogSize: const Size(325, 400),
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
||||||
|
value:
|
||||||
|
controller.text.isNotEmpty
|
||||||
|
? [_parseDate(controller.text)]
|
||||||
|
: <DateTime?>[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (results != null && results.isNotEmpty && results[0] != null) {
|
||||||
|
final selectedDate = results[0]!;
|
||||||
|
final formattedDate = DateFormat(dateFormat).format(selectedDate);
|
||||||
|
controller.text = formattedDate;
|
||||||
|
onDateSelected(selectedDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _parseDate(String dateStr) {
|
||||||
|
try {
|
||||||
|
return DateFormat(dateFormat).parse(dateStr);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,53 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
|
class FormSectionHeader extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final String? additionalText;
|
||||||
|
|
||||||
|
const FormSectionHeader({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
this.additionalText,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeLg,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: TColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (subtitle != null) ...[
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.copyWith(color: TColors.textSecondary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (additionalText != null) ...[
|
||||||
|
const SizedBox(height: TSizes.xs),
|
||||||
|
Text(
|
||||||
|
additionalText!,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
color: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
|
class GenderSelection extends StatelessWidget {
|
||||||
|
final String selectedGender;
|
||||||
|
final Function(String?) onGenderChanged;
|
||||||
|
final String? errorText;
|
||||||
|
|
||||||
|
const GenderSelection({
|
||||||
|
super.key,
|
||||||
|
required this.selectedGender,
|
||||||
|
required this.onGenderChanged,
|
||||||
|
this.errorText,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
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: selectedGender,
|
||||||
|
onChanged: onGenderChanged,
|
||||||
|
activeColor: TColors.primary,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: RadioListTile<String>(
|
||||||
|
title: const Text('Female'),
|
||||||
|
value: 'Female',
|
||||||
|
groupValue: selectedGender,
|
||||||
|
onChanged: onGenderChanged,
|
||||||
|
activeColor: TColors.primary,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (errorText != null && errorText!.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: TSizes.xs, left: TSizes.xs),
|
||||||
|
child: Text(
|
||||||
|
errorText!,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: TColors.error,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
|
class VerificationStatus extends StatelessWidget {
|
||||||
|
final bool isVerifying;
|
||||||
|
final String verifyingMessage;
|
||||||
|
|
||||||
|
const VerificationStatus({
|
||||||
|
super.key,
|
||||||
|
required this.isVerifying,
|
||||||
|
required this.verifyingMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return isVerifying
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: TSizes.spaceBtwItems),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(verifyingMessage),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,93 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
|
class ImageSourceDialog {
|
||||||
|
static Future<void> show({
|
||||||
|
required String title,
|
||||||
|
required String message,
|
||||||
|
required Function(ImageSource source) onSourceSelected,
|
||||||
|
bool galleryOption = true,
|
||||||
|
}) {
|
||||||
|
return 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: () {
|
||||||
|
onSourceSelected(ImageSource.camera);
|
||||||
|
Get.back();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (galleryOption)
|
||||||
|
_buildImageSourceOption(
|
||||||
|
icon: Icons.image,
|
||||||
|
label: 'Gallery',
|
||||||
|
onTap: () {
|
||||||
|
onSourceSelected(ImageSource.gallery);
|
||||||
|
Get.back();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static 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,325 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
|
class ImageUploader extends StatelessWidget {
|
||||||
|
final XFile? image;
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final String? errorMessage;
|
||||||
|
final bool isUploading;
|
||||||
|
final bool isVerifying;
|
||||||
|
final bool isConfirmed;
|
||||||
|
final VoidCallback onTapToSelect;
|
||||||
|
final VoidCallback? onClear;
|
||||||
|
final VoidCallback? onValidate;
|
||||||
|
final IconData placeholderIcon;
|
||||||
|
final double height;
|
||||||
|
final Widget? processingOverlay;
|
||||||
|
final Widget? errorOverlay;
|
||||||
|
|
||||||
|
const ImageUploader({
|
||||||
|
super.key,
|
||||||
|
required this.image,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
this.errorMessage,
|
||||||
|
required this.isUploading,
|
||||||
|
required this.isVerifying,
|
||||||
|
required this.isConfirmed,
|
||||||
|
required this.onTapToSelect,
|
||||||
|
this.onClear,
|
||||||
|
this.onValidate,
|
||||||
|
this.placeholderIcon = Icons.add_a_photo,
|
||||||
|
this.height = 180,
|
||||||
|
this.processingOverlay,
|
||||||
|
this.errorOverlay,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Background color based on error state or confirmation
|
||||||
|
final backgroundColor =
|
||||||
|
errorMessage != null && errorMessage!.isNotEmpty
|
||||||
|
? TColors.error.withOpacity(0.1)
|
||||||
|
: isConfirmed
|
||||||
|
? Colors.green.withOpacity(0.1)
|
||||||
|
: Colors.grey.withOpacity(0.1);
|
||||||
|
|
||||||
|
// Determine border color based on error state or confirmation
|
||||||
|
final borderColor =
|
||||||
|
errorMessage != null && errorMessage!.isNotEmpty
|
||||||
|
? TColors.error
|
||||||
|
: isConfirmed
|
||||||
|
? Colors.green
|
||||||
|
: Colors.grey.withOpacity(0.5);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (image == null)
|
||||||
|
_buildEmptyUploader(backgroundColor, borderColor)
|
||||||
|
else
|
||||||
|
_buildImagePreview(borderColor),
|
||||||
|
|
||||||
|
// Show file size information if image is uploaded
|
||||||
|
if (image != null)
|
||||||
|
FutureBuilder<int>(
|
||||||
|
future: File(image!.path).length(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasData) {
|
||||||
|
final fileSizeKB = snapshot.data! / 1024;
|
||||||
|
final fileSizeMB = fileSizeKB / 1024;
|
||||||
|
final isOversized = fileSizeMB > 4; // 4MB limit
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: TSizes.sm),
|
||||||
|
child: Text(
|
||||||
|
'File size: ${fileSizeMB.toStringAsFixed(2)} MB',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
color:
|
||||||
|
isOversized ? TColors.error : TColors.textSecondary,
|
||||||
|
fontWeight:
|
||||||
|
isOversized ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyUploader(Color backgroundColor, Color borderColor) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTapToSelect,
|
||||||
|
child: Container(
|
||||||
|
height: height,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
||||||
|
border: Border.all(
|
||||||
|
color: borderColor,
|
||||||
|
width: errorMessage != null && errorMessage!.isNotEmpty ? 2 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
isUploading
|
||||||
|
? 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(
|
||||||
|
errorMessage != null && errorMessage!.isNotEmpty
|
||||||
|
? Icons.error_outline
|
||||||
|
: placeholderIcon,
|
||||||
|
size: TSizes.iconLg,
|
||||||
|
color:
|
||||||
|
errorMessage != null && errorMessage!.isNotEmpty
|
||||||
|
? TColors.error
|
||||||
|
: Colors.grey,
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
errorMessage != null && errorMessage!.isNotEmpty
|
||||||
|
? 'Please upload an image first'
|
||||||
|
: title,
|
||||||
|
style: TextStyle(
|
||||||
|
color:
|
||||||
|
errorMessage != null && errorMessage!.isNotEmpty
|
||||||
|
? TColors.error
|
||||||
|
: TColors.textSecondary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.xs),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
color:
|
||||||
|
errorMessage != null && errorMessage!.isNotEmpty
|
||||||
|
? TColors.error.withOpacity(0.8)
|
||||||
|
: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildImagePreview(Color borderColor) {
|
||||||
|
return 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,
|
||||||
|
),
|
||||||
|
child: Image.file(
|
||||||
|
File(image!.path),
|
||||||
|
height: 200,
|
||||||
|
width: double.infinity,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Error overlay
|
||||||
|
if (errorMessage != null &&
|
||||||
|
errorMessage!.isNotEmpty &&
|
||||||
|
!isUploading &&
|
||||||
|
!isVerifying)
|
||||||
|
errorOverlay ?? _defaultErrorOverlay(),
|
||||||
|
|
||||||
|
// Loading overlay
|
||||||
|
if (isUploading || isVerifying)
|
||||||
|
processingOverlay ?? _defaultProcessingOverlay(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Close button
|
||||||
|
if (!isConfirmed && !isUploading && !isVerifying && onClear != null)
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onClear,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(TSizes.xs),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
errorMessage != null && errorMessage!.isNotEmpty
|
||||||
|
? TColors.error
|
||||||
|
: Colors.black54,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.close,
|
||||||
|
color: Colors.white,
|
||||||
|
size: TSizes.iconSm,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Validate button
|
||||||
|
if (onValidate != null && !isVerifying && !isUploading && !isConfirmed)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: TSizes.sm),
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: onValidate,
|
||||||
|
icon: const Icon(Icons.check_circle),
|
||||||
|
label: Text('Verify Image'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: TColors.primary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
minimumSize: const Size(double.infinity, 50),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _defaultErrorOverlay() {
|
||||||
|
return Container(
|
||||||
|
height: 200,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd - 2),
|
||||||
|
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(
|
||||||
|
errorMessage ?? 'Please try another image',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: TColors.error,
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _defaultProcessingOverlay() {
|
||||||
|
return Container(
|
||||||
|
height: 200,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd - 2),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,79 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
|
class TipsContainer extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final List<String> tips;
|
||||||
|
final Color backgroundColor;
|
||||||
|
final Color borderColor;
|
||||||
|
final Color textColor;
|
||||||
|
final Color iconColor;
|
||||||
|
final IconData leadingIcon;
|
||||||
|
|
||||||
|
const TipsContainer({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.tips,
|
||||||
|
this.backgroundColor = Colors.blue,
|
||||||
|
this.borderColor = Colors.blue,
|
||||||
|
this.textColor = Colors.blue,
|
||||||
|
this.iconColor = Colors.blue,
|
||||||
|
this.leadingIcon = Icons.tips_and_updates,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(TSizes.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusSm),
|
||||||
|
border: Border.all(color: borderColor.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(leadingIcon, color: iconColor, size: TSizes.iconMd),
|
||||||
|
const SizedBox(width: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, color: textColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
...tips.map((tip) => _buildTipItem(tip)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTipItem(String tip) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: TSizes.xs),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"• ",
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor.withOpacity(0.8),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
tip,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
color: textColor.withOpacity(0.8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -112,7 +112,7 @@ class CustomTextField extends StatelessWidget {
|
||||||
// ).textTheme.bodySmall?.copyWith(color: TColors.error),
|
// ).textTheme.bodySmall?.copyWith(color: TColors.error),
|
||||||
// ),
|
// ),
|
||||||
// ),
|
// ),
|
||||||
// const SizedBox(height: TSizes.spaceBtwInputFields),
|
const SizedBox(height: TSizes.spaceBtwInputFields),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,303 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sigap/src/features/daily-ops/data/models/models/kta_model.dart';
|
||||||
|
import 'package:sigap/src/features/personalization/data/models/models/ktp_model.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
|
class OcrResultCard extends StatelessWidget {
|
||||||
|
final Map<String, String> extractedInfo;
|
||||||
|
final bool isOfficer;
|
||||||
|
final bool isValid;
|
||||||
|
|
||||||
|
const OcrResultCard({
|
||||||
|
super.key,
|
||||||
|
required this.extractedInfo,
|
||||||
|
required this.isOfficer,
|
||||||
|
this.isValid = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final String idCardType = isOfficer ? 'KTA' : 'KTP';
|
||||||
|
|
||||||
|
// Convert to model
|
||||||
|
final model =
|
||||||
|
isOfficer
|
||||||
|
? _convertToKtaModel(extractedInfo)
|
||||||
|
: _convertToKtpModel(extractedInfo);
|
||||||
|
|
||||||
|
if (extractedInfo.isEmpty) {
|
||||||
|
return _buildFallbackCard(idCardType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
||||||
|
side: BorderSide(
|
||||||
|
color: isValid ? Colors.green : Colors.orange,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(TSizes.md),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildHeaderRow(idCardType),
|
||||||
|
const Divider(height: TSizes.spaceBtwItems),
|
||||||
|
isOfficer
|
||||||
|
? _buildKtaInfoRows(model as KtaModel)
|
||||||
|
: _buildKtpInfoRows(model as KtpModel),
|
||||||
|
if (!isValid) _buildWarningMessage(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert extracted info to KTP model
|
||||||
|
KtpModel _convertToKtpModel(Map<String, String> info) {
|
||||||
|
return KtpModel(
|
||||||
|
nik: info['nik'] ?? '',
|
||||||
|
name: info['nama'] ?? '',
|
||||||
|
birthPlace: info['tempat_lahir'] ?? info['birthPlace'] ?? '',
|
||||||
|
birthDate: info['tanggal_lahir'] ?? info['birthDate'] ?? '',
|
||||||
|
gender: info['gender'] ?? info['jenis_kelamin'] ?? '',
|
||||||
|
address: info['alamat'] ?? info['address'] ?? '',
|
||||||
|
nationality: info['nationality'] ?? info['kewarganegaraan'] ?? 'WNI',
|
||||||
|
religion: info['religion'] ?? info['agama'],
|
||||||
|
occupation: info['occupation'] ?? info['pekerjaan'],
|
||||||
|
maritalStatus: info['marital_status'] ?? info['status_perkawinan'],
|
||||||
|
rtRw: info['rt_rw'],
|
||||||
|
kelurahan: info['kelurahan'] ?? info['desa'],
|
||||||
|
kecamatan: info['kecamatan'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert extracted info to KTA model
|
||||||
|
KtaModel _convertToKtaModel(Map<String, String> info) {
|
||||||
|
return KtaModel(
|
||||||
|
name: info['nama'] ?? '',
|
||||||
|
nrp: info['nrp'] ?? '',
|
||||||
|
policeUnit: info['unit'] ?? info['kesatuan'] ?? '',
|
||||||
|
issueDate: info['tanggal_terbit'] ?? info['issue_date'] ?? '',
|
||||||
|
cardNumber: info['nomor_kartu'] ?? info['card_number'] ?? '',
|
||||||
|
extraData: {
|
||||||
|
'pangkat': info['pangkat'] ?? '',
|
||||||
|
'tanggal_lahir': info['tanggal_lahir'] ?? '',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeaderRow(String idCardType) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isValid ? Icons.check_circle : Icons.info,
|
||||||
|
color: isValid ? Colors.green : Colors.orange,
|
||||||
|
size: TSizes.iconMd,
|
||||||
|
),
|
||||||
|
const SizedBox(width: TSizes.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Extracted $idCardType Information',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: TSizes.fontSizeMd,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildKtpInfoRows(KtpModel model) {
|
||||||
|
// Only show non-empty fields
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (model.nik.isNotEmpty) _buildInfoRow('NIK', model.nik),
|
||||||
|
if (model.name.isNotEmpty) _buildInfoRow('Name', model.name),
|
||||||
|
if (model.birthPlace.isNotEmpty)
|
||||||
|
_buildInfoRow('Birth Place', model.birthPlace),
|
||||||
|
if (model.birthDate.isNotEmpty)
|
||||||
|
_buildInfoRow('Birth Date', model.birthDate),
|
||||||
|
if (model.gender.isNotEmpty) _buildInfoRow('Gender', model.gender),
|
||||||
|
if (model.address.isNotEmpty) _buildInfoRow('Address', model.address),
|
||||||
|
if (model.religion != null && model.religion!.isNotEmpty)
|
||||||
|
_buildInfoRow('Religion', model.religion!),
|
||||||
|
if (model.maritalStatus != null && model.maritalStatus!.isNotEmpty)
|
||||||
|
_buildInfoRow('Marital Status', model.maritalStatus!),
|
||||||
|
if (model.occupation != null && model.occupation!.isNotEmpty)
|
||||||
|
_buildInfoRow('Occupation', model.occupation!),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildKtaInfoRows(KtaModel model) {
|
||||||
|
// Get additional fields from extraData
|
||||||
|
final pangkat = model.extraData?['pangkat'] as String? ?? '';
|
||||||
|
final tanggalLahir = model.extraData?['tanggal_lahir'] as String? ?? '';
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (model.name.isNotEmpty) _buildInfoRow('Name', model.name),
|
||||||
|
if (model.nrp.isNotEmpty) _buildInfoRow('NRP', model.nrp),
|
||||||
|
if (pangkat.isNotEmpty) _buildInfoRow('Rank', pangkat),
|
||||||
|
if (model.policeUnit.isNotEmpty)
|
||||||
|
_buildInfoRow('Unit', model.policeUnit),
|
||||||
|
if (tanggalLahir.isNotEmpty) _buildInfoRow('Birth Date', tanggalLahir),
|
||||||
|
if (model.issueDate.isNotEmpty)
|
||||||
|
_buildInfoRow('Issue Date', model.issueDate),
|
||||||
|
if (model.cardNumber.isNotEmpty)
|
||||||
|
_buildInfoRow('Card Number', model.cardNumber),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoRow(String label, String value) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: TSizes.sm),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 100,
|
||||||
|
child: Text(
|
||||||
|
'$label:',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: TSizes.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(color: TColors.textPrimary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildWarningMessage() {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(top: TSizes.sm),
|
||||||
|
padding: const EdgeInsets.all(TSizes.sm),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusSm),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.warning_amber_rounded,
|
||||||
|
color: Colors.orange,
|
||||||
|
size: TSizes.iconSm,
|
||||||
|
),
|
||||||
|
const SizedBox(width: TSizes.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Some information might be missing or incorrect. Please verify the extracted data.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeXs,
|
||||||
|
color: Colors.orange.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFallbackCard(String idCardType) {
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
||||||
|
side: BorderSide(color: Colors.red.shade300, width: 1.5),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(TSizes.md),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
color: Colors.red.shade400,
|
||||||
|
size: TSizes.iconMd,
|
||||||
|
),
|
||||||
|
const SizedBox(width: TSizes.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Unable to Extract $idCardType Information',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: TSizes.fontSizeMd,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(height: TSizes.spaceBtwItems),
|
||||||
|
Text(
|
||||||
|
'We could not automatically extract information from your $idCardType. Please make sure:',
|
||||||
|
style: TextStyle(
|
||||||
|
color: TColors.textSecondary,
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
_buildFallbackTipItem('The image is clear and not blurry'),
|
||||||
|
_buildFallbackTipItem('All text on the $idCardType is visible'),
|
||||||
|
_buildFallbackTipItem('There is good lighting with no glare'),
|
||||||
|
_buildFallbackTipItem('The entire card is visible in the frame'),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Text(
|
||||||
|
'Please take a new picture of your $idCardType and try again.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
color: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFallbackTipItem(String tip) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: TSizes.xs),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'• ',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.red.shade700,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
tip,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: TSizes.fontSizeSm,
|
||||||
|
color: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,105 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
|
class ValidationMessageCard extends StatelessWidget {
|
||||||
|
final String message;
|
||||||
|
final bool isValid;
|
||||||
|
final bool hasConfirmed;
|
||||||
|
final VoidCallback? onConfirm;
|
||||||
|
final VoidCallback? onTryAnother;
|
||||||
|
|
||||||
|
const ValidationMessageCard({
|
||||||
|
super.key,
|
||||||
|
required this.message,
|
||||||
|
required this.isValid,
|
||||||
|
this.hasConfirmed = false,
|
||||||
|
this.onConfirm,
|
||||||
|
this.onTryAnother,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(TSizes.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
isValid
|
||||||
|
? 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(
|
||||||
|
isValid ? Icons.check_circle : Icons.error,
|
||||||
|
color: isValid ? Colors.green : Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: TSizes.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: TextStyle(color: isValid ? Colors.green : Colors.red),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (isValid &&
|
||||||
|
!hasConfirmed &&
|
||||||
|
onConfirm != null &&
|
||||||
|
onTryAnother != null) ...[
|
||||||
|
const SizedBox(height: TSizes.md),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: onConfirm,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
child: const Text('Confirm Image'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: TSizes.sm),
|
||||||
|
Expanded(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: onTryAnother,
|
||||||
|
child: const Text('Try Another Image'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (hasConfirmed)
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,181 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:logger/logger.dart';
|
||||||
|
|
||||||
|
class FormKeyDebugger {
|
||||||
|
static final Logger _logger = Logger();
|
||||||
|
static final Map<String, GlobalKey> _trackedKeys = {};
|
||||||
|
static final Set<String> _loggedCombinations = {};
|
||||||
|
static DateTime? _lastDebugTime;
|
||||||
|
static const Duration _debugCooldown = Duration(milliseconds: 500);
|
||||||
|
|
||||||
|
/// Register a form key for tracking
|
||||||
|
static void registerFormKey(String identifier, GlobalKey key) {
|
||||||
|
if (!kDebugMode) return;
|
||||||
|
|
||||||
|
_trackedKeys[identifier] = key;
|
||||||
|
_logger.d('🔑 Registered form key: $identifier -> ${key.toString()}');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debug all form keys with cooldown to prevent spam
|
||||||
|
static void debugAllFormKeys() {
|
||||||
|
if (!kDebugMode) return;
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (_lastDebugTime != null &&
|
||||||
|
now.difference(_lastDebugTime!) < _debugCooldown) {
|
||||||
|
return; // Skip if called too frequently
|
||||||
|
}
|
||||||
|
_lastDebugTime = now;
|
||||||
|
|
||||||
|
if (_trackedKeys.isEmpty) {
|
||||||
|
_logger.d('🔍 No form keys registered for debugging');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.d('🔍 === FORM KEY DEBUG SESSION ===');
|
||||||
|
_logger.d('📊 Total tracked keys: ${_trackedKeys.length}');
|
||||||
|
|
||||||
|
final keyGroups = <String, List<String>>{};
|
||||||
|
final duplicates = <String>[];
|
||||||
|
|
||||||
|
// Group keys by their string representation
|
||||||
|
_trackedKeys.forEach((identifier, key) {
|
||||||
|
final keyString = key.toString();
|
||||||
|
keyGroups.putIfAbsent(keyString, () => []).add(identifier);
|
||||||
|
|
||||||
|
_logger.d('🔑 $identifier: $keyString (hash: ${key.hashCode})');
|
||||||
|
_logger.d(
|
||||||
|
' - Current context: ${key.currentContext != null ? "✅" : "❌"}',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (key is GlobalKey<FormState>) {
|
||||||
|
_logger.d(' - Form state: ${key.currentState != null ? "✅" : "❌"}');
|
||||||
|
_logger.d(' - Form mounted: ${key.currentState?.mounted ?? false}');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for duplicates
|
||||||
|
keyGroups.forEach((keyString, identifiers) {
|
||||||
|
if (identifiers.length > 1) {
|
||||||
|
duplicates.add(keyString);
|
||||||
|
_logger.e('❌ DUPLICATE KEY DETECTED!');
|
||||||
|
_logger.e(' Key: $keyString');
|
||||||
|
_logger.e(' Used by: ${identifiers.join(", ")}');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (duplicates.isEmpty) {
|
||||||
|
_logger.d('✅ No duplicate keys found');
|
||||||
|
} else {
|
||||||
|
_logger.e('❌ Found ${duplicates.length} duplicate key(s)');
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.d('🔍 === END DEBUG SESSION ===');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debug a specific controller's form key
|
||||||
|
static void debugControllerFormKey(String source, GlobalKey formKey) {
|
||||||
|
if (!kDebugMode) return;
|
||||||
|
|
||||||
|
final combination = '${source}_${formKey.toString()}';
|
||||||
|
if (_loggedCombinations.contains(combination)) {
|
||||||
|
return; // Already logged this combination
|
||||||
|
}
|
||||||
|
|
||||||
|
_loggedCombinations.add(combination);
|
||||||
|
|
||||||
|
// Use microtask to avoid blocking UI
|
||||||
|
Future.microtask(() {
|
||||||
|
_logger.d(
|
||||||
|
'🔑 Controller "$source" form key: $formKey (hashCode: ${formKey.hashCode})',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (formKey is GlobalKey<FormState>) {
|
||||||
|
_logger.d(' - Form state exists: ${formKey.currentState != null}');
|
||||||
|
_logger.d(
|
||||||
|
' - Form mounted: ${formKey.currentState?.mounted ?? false}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a key is already being tracked
|
||||||
|
static bool isKeyTracked(GlobalKey key) {
|
||||||
|
return _trackedKeys.containsValue(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all tracked keys
|
||||||
|
static Map<String, GlobalKey> getTrackedKeys() {
|
||||||
|
return Map.unmodifiable(_trackedKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all tracking (useful for testing)
|
||||||
|
static void clearAll() {
|
||||||
|
_trackedKeys.clear();
|
||||||
|
_loggedCombinations.clear();
|
||||||
|
_lastDebugTime = null;
|
||||||
|
_logger.d('🧹 FormKeyDebugger cleared all tracking data');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unregister a specific key
|
||||||
|
static void unregisterFormKey(String identifier) {
|
||||||
|
if (_trackedKeys.containsKey(identifier)) {
|
||||||
|
_trackedKeys.remove(identifier);
|
||||||
|
_logger.d('🗑️ Unregistered form key: $identifier');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate form keys for common issues
|
||||||
|
static void validateFormKeys() {
|
||||||
|
if (!kDebugMode) return;
|
||||||
|
|
||||||
|
_logger.d('🔍 Validating form keys...');
|
||||||
|
|
||||||
|
final issues = <String>[];
|
||||||
|
|
||||||
|
_trackedKeys.forEach((identifier, key) {
|
||||||
|
// Check if key has context
|
||||||
|
if (key.currentContext == null) {
|
||||||
|
issues.add('$identifier: No context');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if FormState key has state
|
||||||
|
if (key is GlobalKey<FormState> && key.currentState == null) {
|
||||||
|
issues.add('$identifier: No form state');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if widget is mounted
|
||||||
|
if (key is GlobalKey<FormState> &&
|
||||||
|
key.currentState != null &&
|
||||||
|
!key.currentState!.mounted) {
|
||||||
|
issues.add('$identifier: Form not mounted');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (issues.isEmpty) {
|
||||||
|
_logger.d('✅ All form keys are valid');
|
||||||
|
} else {
|
||||||
|
_logger.w('⚠️ Found ${issues.length} form key issue(s):');
|
||||||
|
for (final issue in issues) {
|
||||||
|
_logger.w(' - $issue');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a unique form key with automatic registration
|
||||||
|
static GlobalKey<FormState> createFormKey(String identifier) {
|
||||||
|
final key = GlobalKey<FormState>(debugLabel: identifier);
|
||||||
|
registerFormKey(identifier, key);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a unique form key with timestamp to prevent duplicates
|
||||||
|
static GlobalKey<FormState> createUniqueFormKey(String identifier) {
|
||||||
|
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final uniqueId = '${identifier}_$timestamp';
|
||||||
|
final key = GlobalKey<FormState>(debugLabel: uniqueId);
|
||||||
|
registerFormKey(identifier, key);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,357 @@
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:page_transition/page_transition.dart';
|
||||||
|
|
||||||
|
enum SplashType { simpleSplash, backgroundScreenReturn }
|
||||||
|
|
||||||
|
enum SplashTransition {
|
||||||
|
slideTransition,
|
||||||
|
scaleTransition,
|
||||||
|
rotationTransition,
|
||||||
|
|
||||||
|
sizeTransition,
|
||||||
|
fadeTransition,
|
||||||
|
decoratedBoxTransition,
|
||||||
|
}
|
||||||
|
|
||||||
|
class AnimatedSplashScreen extends StatefulWidget {
|
||||||
|
/// Type of page transition
|
||||||
|
final PageTransitionType transitionType;
|
||||||
|
|
||||||
|
/// Type of splash transition
|
||||||
|
final SplashTransition splashTransition;
|
||||||
|
|
||||||
|
/// Only required while using [AnimatedSplashScreen.withScreenFunction]
|
||||||
|
/// here you pass your function that needs to be called before jumping
|
||||||
|
/// on to the next screen
|
||||||
|
final Future Function()? function;
|
||||||
|
|
||||||
|
/// Custom animation to icon of splash
|
||||||
|
final Animatable? customAnimation;
|
||||||
|
|
||||||
|
/// Background color
|
||||||
|
final Color backgroundColor;
|
||||||
|
|
||||||
|
/// Compulsory in default constructor.
|
||||||
|
/// Here you pass your widget that will be browsed
|
||||||
|
final Widget? nextScreen;
|
||||||
|
|
||||||
|
/// Type of AnimatedSplashScreen
|
||||||
|
final SplashType type;
|
||||||
|
|
||||||
|
/// to make the icon in [Center] of splash
|
||||||
|
final bool centered;
|
||||||
|
|
||||||
|
/// If you want to implement the navigation to the next page yourself.
|
||||||
|
/// By default this is [false], the widget will call Navigator.of(_context).pushReplacement()
|
||||||
|
/// using PageTransition with [transitionType] after [duration] to [nextScreen]
|
||||||
|
final bool disableNavigation;
|
||||||
|
|
||||||
|
/// It can be string for [Image.asserts], normal [Widget] or you can user tags
|
||||||
|
/// to choose which one you image type, for example:
|
||||||
|
/// * '[n]www.my-site.com/my-image.png' to [Image.network]
|
||||||
|
final dynamic splash;
|
||||||
|
|
||||||
|
/// Time in milliseconds after splash animation to jump to next screen
|
||||||
|
/// Default is [milliseconds: 2500], minimum is [milliseconds: 100]
|
||||||
|
final int duration;
|
||||||
|
|
||||||
|
/// Curve of splash animation
|
||||||
|
final Curve curve;
|
||||||
|
|
||||||
|
/// Splash animation duration, default is [milliseconds: 800]
|
||||||
|
final Duration? animationDuration;
|
||||||
|
|
||||||
|
/// Size of an icon in splash screen
|
||||||
|
final double? splashIconSize;
|
||||||
|
|
||||||
|
/// Here you pass your route that will be browsed
|
||||||
|
final String? nextRoute;
|
||||||
|
|
||||||
|
factory AnimatedSplashScreen({
|
||||||
|
Curve curve = Curves.easeInCirc,
|
||||||
|
Future Function()? function,
|
||||||
|
int duration = 2500,
|
||||||
|
required dynamic splash,
|
||||||
|
required Widget nextScreen,
|
||||||
|
Color backgroundColor = Colors.white,
|
||||||
|
Animatable? customTween,
|
||||||
|
bool centered = true,
|
||||||
|
bool disableNavigation = false,
|
||||||
|
SplashTransition? splashTransition,
|
||||||
|
PageTransitionType? pageTransitionType,
|
||||||
|
Duration? animationDuration,
|
||||||
|
double? splashIconSize,
|
||||||
|
String? nextRoute,
|
||||||
|
}) {
|
||||||
|
return AnimatedSplashScreen._internal(
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
animationDuration: animationDuration,
|
||||||
|
transitionType: pageTransitionType ?? PageTransitionType.bottomToTop,
|
||||||
|
splashTransition: splashTransition ?? SplashTransition.fadeTransition,
|
||||||
|
splashIconSize: splashIconSize,
|
||||||
|
customAnimation: customTween,
|
||||||
|
function: function,
|
||||||
|
nextRoute: nextRoute,
|
||||||
|
duration: duration,
|
||||||
|
centered: centered,
|
||||||
|
disableNavigation: disableNavigation,
|
||||||
|
splash: splash,
|
||||||
|
type: SplashType.simpleSplash,
|
||||||
|
nextScreen: nextScreen,
|
||||||
|
curve: curve,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory AnimatedSplashScreen.withScreenFunction({
|
||||||
|
Curve curve = Curves.easeInCirc,
|
||||||
|
bool centered = true,
|
||||||
|
bool disableNavigation = false,
|
||||||
|
int duration = 2500,
|
||||||
|
required dynamic splash,
|
||||||
|
required Future<Widget> Function() screenFunction,
|
||||||
|
Animatable? customTween,
|
||||||
|
Color backgroundColor = Colors.white,
|
||||||
|
SplashTransition? splashTransition,
|
||||||
|
PageTransitionType? pageTransitionType,
|
||||||
|
Duration? animationDuration,
|
||||||
|
double? splashIconSize,
|
||||||
|
}) {
|
||||||
|
return AnimatedSplashScreen._internal(
|
||||||
|
type: SplashType.backgroundScreenReturn,
|
||||||
|
animationDuration: animationDuration,
|
||||||
|
transitionType: pageTransitionType ?? PageTransitionType.bottomToTop,
|
||||||
|
splashTransition: splashTransition ?? SplashTransition.fadeTransition,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
splashIconSize: splashIconSize,
|
||||||
|
customAnimation: customTween,
|
||||||
|
function: screenFunction,
|
||||||
|
duration: duration,
|
||||||
|
centered: centered,
|
||||||
|
disableNavigation: disableNavigation,
|
||||||
|
nextRoute: null,
|
||||||
|
nextScreen: null,
|
||||||
|
splash: splash,
|
||||||
|
curve: curve,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory AnimatedSplashScreen.withScreenRouteFunction({
|
||||||
|
Curve curve = Curves.easeInCirc,
|
||||||
|
bool centered = true,
|
||||||
|
bool disableNavigation = false,
|
||||||
|
int duration = 2500,
|
||||||
|
required dynamic splash,
|
||||||
|
required Future<String> Function() screenRouteFunction,
|
||||||
|
Animatable? customTween,
|
||||||
|
Color backgroundColor = Colors.white,
|
||||||
|
SplashTransition? splashTransition,
|
||||||
|
PageTransitionType? pageTransitionType,
|
||||||
|
Duration? animationDuration,
|
||||||
|
double? splashIconSize,
|
||||||
|
}) {
|
||||||
|
return AnimatedSplashScreen._internal(
|
||||||
|
type: SplashType.backgroundScreenReturn,
|
||||||
|
animationDuration: animationDuration,
|
||||||
|
transitionType: pageTransitionType ?? PageTransitionType.bottomToTop,
|
||||||
|
splashTransition: splashTransition ?? SplashTransition.fadeTransition,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
splashIconSize: splashIconSize,
|
||||||
|
customAnimation: customTween,
|
||||||
|
function: screenRouteFunction,
|
||||||
|
duration: duration,
|
||||||
|
centered: centered,
|
||||||
|
disableNavigation: disableNavigation,
|
||||||
|
nextRoute: null,
|
||||||
|
nextScreen: null,
|
||||||
|
splash: splash,
|
||||||
|
curve: curve,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const AnimatedSplashScreen._internal({
|
||||||
|
required this.animationDuration,
|
||||||
|
required this.splashTransition,
|
||||||
|
required this.customAnimation,
|
||||||
|
required this.backgroundColor,
|
||||||
|
required this.transitionType,
|
||||||
|
required this.splashIconSize,
|
||||||
|
required this.nextScreen,
|
||||||
|
required this.function,
|
||||||
|
required this.duration,
|
||||||
|
required this.centered,
|
||||||
|
required this.disableNavigation,
|
||||||
|
required this.splash,
|
||||||
|
required this.curve,
|
||||||
|
required this.type,
|
||||||
|
required this.nextRoute,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_AnimatedSplashScreenState createState() => _AnimatedSplashScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AnimatedSplashScreenState extends State<AnimatedSplashScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _animationController;
|
||||||
|
static late BuildContext _context;
|
||||||
|
late Animation _animation;
|
||||||
|
|
||||||
|
AnimatedSplashScreen get w => widget;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
_animationController = AnimationController(
|
||||||
|
duration: w.animationDuration ?? Duration(milliseconds: 800),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
Animatable animation =
|
||||||
|
w.customAnimation ??
|
||||||
|
() {
|
||||||
|
switch (w.splashTransition) {
|
||||||
|
case SplashTransition.slideTransition:
|
||||||
|
return Tween<Offset>(end: Offset.zero, begin: Offset(1, 0));
|
||||||
|
|
||||||
|
case SplashTransition.decoratedBoxTransition:
|
||||||
|
return DecorationTween(
|
||||||
|
end: BoxDecoration(color: Colors.black87),
|
||||||
|
begin: BoxDecoration(color: Colors.redAccent),
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return Tween(begin: 0.0, end: 1.0);
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
as Animatable<dynamic>;
|
||||||
|
|
||||||
|
_animation = animation.animate(
|
||||||
|
CurvedAnimation(parent: _animationController, curve: w.curve),
|
||||||
|
);
|
||||||
|
_animationController.forward().then((value) => doTransition());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// call function case needed and then jump to next screen
|
||||||
|
doTransition() async {
|
||||||
|
if (w.type == SplashType.backgroundScreenReturn)
|
||||||
|
navigator(await w.function!());
|
||||||
|
else if (!w.disableNavigation)
|
||||||
|
navigator(w.nextRoute ?? w.nextScreen);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_animationController.reset();
|
||||||
|
_animationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator(screen) {
|
||||||
|
Future.delayed(
|
||||||
|
Duration(milliseconds: w.duration < 100 ? 100 : w.duration),
|
||||||
|
).then((_) {
|
||||||
|
try {
|
||||||
|
if (screen is String) {
|
||||||
|
Navigator.of(_context).pushReplacementNamed(screen);
|
||||||
|
} else {
|
||||||
|
Navigator.of(_context).pushReplacement(
|
||||||
|
PageTransition(type: w.transitionType, child: screen),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (msg) {
|
||||||
|
print(
|
||||||
|
'AnimatedSplashScreen -> '
|
||||||
|
'error in jump to next screen, probably '
|
||||||
|
'this run is in hot reload: $msg',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return icon of splash screen
|
||||||
|
Widget getSplash() {
|
||||||
|
final size =
|
||||||
|
w.splashIconSize ?? MediaQuery.of(context).size.shortestSide * 0.2;
|
||||||
|
|
||||||
|
Widget main({required Widget child}) =>
|
||||||
|
w.centered ? Center(child: child) : child;
|
||||||
|
|
||||||
|
return getTransition(
|
||||||
|
child: main(
|
||||||
|
child: SizedBox(
|
||||||
|
height: size,
|
||||||
|
child:
|
||||||
|
w.splash is String
|
||||||
|
? <Widget>() {
|
||||||
|
if (w.splash.toString().contains('[n]'))
|
||||||
|
return Image.network(
|
||||||
|
w.splash.toString().replaceAll('[n]', ''),
|
||||||
|
);
|
||||||
|
else
|
||||||
|
return Image.asset(w.splash);
|
||||||
|
}()
|
||||||
|
: (w.splash is IconData
|
||||||
|
? Icon(w.splash, size: size)
|
||||||
|
: w.splash),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// return transtion
|
||||||
|
Widget getTransition({required Widget child}) {
|
||||||
|
switch (w.splashTransition) {
|
||||||
|
case SplashTransition.slideTransition:
|
||||||
|
return SlideTransition(
|
||||||
|
position: (_animation as Animation<Offset>),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
|
||||||
|
case SplashTransition.scaleTransition:
|
||||||
|
return ScaleTransition(
|
||||||
|
scale: (_animation as Animation<double>),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
|
||||||
|
case SplashTransition.rotationTransition:
|
||||||
|
return RotationTransition(
|
||||||
|
turns: (_animation as Animation<double>),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
|
||||||
|
case SplashTransition.sizeTransition:
|
||||||
|
return SizeTransition(
|
||||||
|
sizeFactor: (_animation as Animation<double>),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
|
||||||
|
case SplashTransition.fadeTransition:
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: (_animation as Animation<double>),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
|
||||||
|
case SplashTransition.decoratedBoxTransition:
|
||||||
|
return DecoratedBoxTransition(
|
||||||
|
decoration: (_animation as Animation<Decoration>),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: (_animation as Animation<double>),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
_context = context;
|
||||||
|
|
||||||
|
return Scaffold(backgroundColor: w.backgroundColor, body: getSplash());
|
||||||
|
}
|
||||||
|
}
|
|
@ -257,6 +257,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.9"
|
version: "2.0.9"
|
||||||
|
equatable:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: equatable
|
||||||
|
sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.7"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
@ -31,6 +31,7 @@ dependencies:
|
||||||
time_slot:
|
time_slot:
|
||||||
calendar_date_picker2:
|
calendar_date_picker2:
|
||||||
easy_date_timeline:
|
easy_date_timeline:
|
||||||
|
equatable: ^2.0.7
|
||||||
|
|
||||||
# --- Logging & Debugging ---
|
# --- Logging & Debugging ---
|
||||||
logger:
|
logger:
|
||||||
|
|
Loading…
Reference in New Issue