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:get/get.dart';
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
import 'package:logger/logger.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/utils/constants/colors.dart';
|
||||
import 'package:sigap/src/utils/constants/image_strings.dart';
|
||||
import 'package:sigap/src/utils/helpers/helper_functions.dart';
|
||||
|
||||
class AnimatedSplashScreenWidget extends StatelessWidget {
|
||||
class AnimatedSplashScreenWidget extends StatefulWidget {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = THelperFunctions.isDarkMode(context);
|
||||
final isFirstTime = storage.read('isFirstTime') ?? false;
|
||||
|
||||
return AnimatedSplashScreen(
|
||||
splash: Center(
|
||||
Logger().i('isFirstTime: $isFirstTime');
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: isDark ? TColors.dark : TColors.white,
|
||||
body: Center(
|
||||
child: FadeTransition(
|
||||
opacity: _animation,
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -117,72 +117,389 @@ class AzureOCRService {
|
|||
final Map<String, String> extractedInfo = {};
|
||||
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++) {
|
||||
print('Line $i: ${allLines[i]}');
|
||||
}
|
||||
|
||||
// Creating a single concatenated text for regex-based extraction
|
||||
final String fullText = allLines.join(' ').toLowerCase();
|
||||
|
||||
// NIK extraction - Look for pattern "NIK: 1234567890123456"
|
||||
RegExp nikRegex = RegExp(r'nik\s*:?\s*(\d{16})');
|
||||
var nikMatch = nikRegex.firstMatch(fullText);
|
||||
if (nikMatch != null && nikMatch.groupCount >= 1) {
|
||||
extractedInfo['nik'] = nikMatch.group(1)!;
|
||||
} else {
|
||||
// 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')) {
|
||||
// NIK label found, check next line or same line after colon
|
||||
String line = allLines[i].toLowerCase();
|
||||
if (line.contains(':')) {
|
||||
String potentialNik = line.split(':')[1].trim();
|
||||
// Clean and validate
|
||||
potentialNik = potentialNik.replaceAll(RegExp(r'[^0-9]'), '');
|
||||
if (potentialNik.length == 16) {
|
||||
extractedInfo['nik'] = potentialNik;
|
||||
}
|
||||
} else if (i + 1 < allLines.length) {
|
||||
// Check next line
|
||||
String potentialNik = allLines[i + 1].trim();
|
||||
// Clean and validate
|
||||
potentialNik = potentialNik.replaceAll(RegExp(r'[^0-9]'), '');
|
||||
if (potentialNik.length == 16) {
|
||||
extractedInfo['nik'] = potentialNik;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Name extraction - Look for pattern "Nama: JOHN DOE"
|
||||
for (int i = 0; i < allLines.length; i++) {
|
||||
String line = allLines[i].toLowerCase();
|
||||
|
||||
// Extract NIK (usually prefixed with "NIK" or "NIK:")
|
||||
if (line.contains('nik') && i + 1 < allLines.length) {
|
||||
// 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:")
|
||||
if (line.contains('nama') && i + 1 < allLines.length) {
|
||||
String name =
|
||||
line.contains(':')
|
||||
? line.split(':')[1].trim()
|
||||
: allLines[i + 1].trim();
|
||||
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();
|
||||
}
|
||||
|
||||
// Extract birth date (usually prefixed with "Tanggal Lahir" or similar)
|
||||
if ((line.contains('lahir') || line.contains('ttl')) &&
|
||||
i + 1 < allLines.length) {
|
||||
String birthInfo =
|
||||
line.contains(':')
|
||||
? line.split(':')[1].trim()
|
||||
: allLines[i + 1].trim();
|
||||
// Try to extract date in format DD-MM-YYYY or similar
|
||||
RegExp dateRegex = RegExp(r'(\d{1,2}[-/\s\.]\d{1,2}[-/\s\.]\d{2,4})');
|
||||
var match = dateRegex.firstMatch(birthInfo);
|
||||
if (match != null) {
|
||||
extractedInfo['tanggal_lahir'] = match.group(0)!;
|
||||
} else if (i + 1 < allLines.length) {
|
||||
// Name on next line
|
||||
extractedInfo['nama'] = allLines[i + 1].trim();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract address (usually prefixed with "Alamat" or similar)
|
||||
if (line.contains('alamat') && i + 1 < allLines.length) {
|
||||
// Address might span multiple lines, try to capture a reasonable amount
|
||||
// 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 = '';
|
||||
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()}';
|
||||
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++;
|
||||
}
|
||||
|
||||
extractedInfo['alamat'] = address.trim();
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
// Use addPostFrameCallback to ensure navigation happens after the build cycle
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
try {
|
||||
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) {
|
||||
// Location is not valid, navigate to warning screen
|
||||
Get.offAllNamed(AppRoutes.locationWarning);
|
||||
|
@ -100,13 +105,14 @@ class AuthenticationRepository extends GetxController {
|
|||
Get.currentRoute != AppRoutes.onboarding) {
|
||||
bool biometricSuccess = await attemptBiometricLogin();
|
||||
if (!biometricSuccess) {
|
||||
// If not first time, go to sign in directly
|
||||
// If first time, show onboarding first
|
||||
storage.writeIfNull('isFirstTime', true);
|
||||
// check if user is already logged in
|
||||
storage.read('isFirstTime') != true
|
||||
? Get.offAllNamed(AppRoutes.signIn)
|
||||
: Get.offAllNamed(AppRoutes.onboarding);
|
||||
// Check if onboarding is completed
|
||||
if (isFirstTime) {
|
||||
// Skip onboarding and go directly to sign in
|
||||
Get.offAllNamed(AppRoutes.signIn);
|
||||
} else {
|
||||
// First time user, show onboarding
|
||||
Get.offAllNamed(AppRoutes.onboarding);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
@ -29,6 +30,8 @@ class FormRegistrationController extends GetxController {
|
|||
late final OfficerInfoController? officerInfoController;
|
||||
late final UnitInfoController? unitInfoController;
|
||||
|
||||
late GlobalKey<FormState> formKey;
|
||||
|
||||
final storage = GetStorage();
|
||||
|
||||
// Current step index
|
||||
|
@ -291,18 +294,18 @@ class FormRegistrationController extends GetxController {
|
|||
bool validateCurrentStep() {
|
||||
switch (currentStep.value) {
|
||||
case 0:
|
||||
return personalInfoController.validate();
|
||||
return personalInfoController.validate(formKey);
|
||||
case 1:
|
||||
return idCardVerificationController.validate();
|
||||
case 2:
|
||||
return selfieVerificationController.validate();
|
||||
return selfieVerificationController.validate(formKey);
|
||||
case 3:
|
||||
return selectedRole.value?.isOfficer == true
|
||||
? officerInfoController!.validate()
|
||||
: identityController.validate();
|
||||
? officerInfoController!.validate(formKey)
|
||||
: identityController.validate(formKey);
|
||||
case 4:
|
||||
return selectedRole.value?.isOfficer == true
|
||||
? unitInfoController!.validate()
|
||||
? unitInfoController!.validate(formKey)
|
||||
: true; // Should not reach here for non-officers
|
||||
default:
|
||||
return true;
|
||||
|
@ -374,16 +377,16 @@ class FormRegistrationController extends GetxController {
|
|||
// Submit the complete form
|
||||
Future<void> submitForm() async {
|
||||
// Validate all steps
|
||||
bool isValid = true;
|
||||
bool isFormValid = true;
|
||||
for (int i = 0; i < totalSteps; i++) {
|
||||
currentStep.value = i;
|
||||
if (!validateCurrentStep()) {
|
||||
isValid = false;
|
||||
isFormValid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValid) return;
|
||||
if (!isFormValid) return;
|
||||
|
||||
try {
|
||||
isLoading.value = false;
|
||||
|
|
|
@ -23,7 +23,7 @@ class SignInController extends GetxController {
|
|||
|
||||
final isLoading = false.obs;
|
||||
|
||||
GlobalKey<FormState> signinFormKey = GlobalKey<FormState>();
|
||||
// GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
|
@ -42,7 +42,7 @@ class SignInController extends GetxController {
|
|||
}
|
||||
|
||||
// Sign in method
|
||||
Future<void> credentialsSignIn() async {
|
||||
Future<void> credentialsSignIn(GlobalKey<FormState> formKey) async {
|
||||
try {
|
||||
// Start loading
|
||||
// TFullScreenLoader.openLoadingDialog(
|
||||
|
@ -61,7 +61,7 @@ class SignInController extends GetxController {
|
|||
}
|
||||
|
||||
// Form validation
|
||||
if (!signinFormKey.currentState!.validate()) {
|
||||
if (!formKey.currentState!.validate()) {
|
||||
// TFullScreenLoader.stopLoading();
|
||||
emailError.value = '';
|
||||
passwordError.value = '';
|
||||
|
@ -103,7 +103,7 @@ class SignInController extends GetxController {
|
|||
}
|
||||
|
||||
// -- Google Sign In Authentication
|
||||
Future<void> googleSignIn() async {
|
||||
Future<void> googleSignIn(GlobalKey<FormState> formKey) async {
|
||||
try {
|
||||
// Start loading
|
||||
// TFullScreenLoader.openLoadingDialog(
|
||||
|
@ -122,7 +122,7 @@ class SignInController extends GetxController {
|
|||
}
|
||||
|
||||
// Form validation
|
||||
if (!signinFormKey.currentState!.validate()) {
|
||||
if (!formKey.currentState!.validate()) {
|
||||
// TFullScreenLoader.stopLoading();
|
||||
emailError.value = '';
|
||||
passwordError.value = '';
|
||||
|
@ -155,7 +155,7 @@ class SignInController extends GetxController {
|
|||
|
||||
// Navigate to sign up screen
|
||||
void goToSignUp() {
|
||||
Get.toNamed(AppRoutes.signUp);
|
||||
Get.toNamed(AppRoutes.signupWithRole);
|
||||
}
|
||||
|
||||
// Navigate to forgot password screen
|
||||
|
|
|
@ -169,14 +169,11 @@ class SignupWithRoleController extends GetxController {
|
|||
// Sign up function
|
||||
/// Updated signup function with better error handling and argument passing
|
||||
void signUp(bool isOfficer) async {
|
||||
if (!validateSignupForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
Logger().i('SignUp process started');
|
||||
|
||||
// Check connection
|
||||
// Check network connection
|
||||
final isConnected = await NetworkManager.instance.isConnected();
|
||||
if (!isConnected) {
|
||||
TLoaders.errorSnackBar(
|
||||
|
@ -186,6 +183,19 @@ class SignupWithRoleController extends GetxController {
|
|||
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
|
||||
if (selectedRoleId.value.isEmpty) {
|
||||
_updateSelectedRoleBasedOnType();
|
||||
|
@ -208,7 +218,6 @@ class SignupWithRoleController extends GetxController {
|
|||
profileStatus: 'incomplete',
|
||||
);
|
||||
|
||||
try {
|
||||
// Create the account
|
||||
final authResponse = await AuthenticationRepository.instance
|
||||
.initialSignUp(
|
||||
|
@ -219,7 +228,11 @@ class SignupWithRoleController extends GetxController {
|
|||
|
||||
// Validate response
|
||||
if (authResponse.session == null || authResponse.user == null) {
|
||||
throw Exception('Failed to create account. Please try again.');
|
||||
TLoaders.errorSnackBar(
|
||||
title: 'Registration Failed',
|
||||
message: 'Failed to create account. Please try again.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final user = authResponse.user!;
|
||||
|
@ -229,21 +242,22 @@ class SignupWithRoleController extends GetxController {
|
|||
await _storeTemporaryData(authResponse, isOfficer);
|
||||
|
||||
// Navigate with arguments
|
||||
AuthenticationRepository.instance.screenRedirect();
|
||||
|
||||
} catch (authError) {
|
||||
Logger().e('Authentication error during signup: $authError');
|
||||
TLoaders.errorSnackBar(
|
||||
title: 'Registration Failed',
|
||||
message: _getReadableErrorMessage(authError.toString()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
Logger().i('Navigating to registration form');
|
||||
// AuthenticationRepository.instance.screenRedirect();
|
||||
} 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(
|
||||
title: 'Registration Failed',
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
message: errorMessage,
|
||||
);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
|
||||
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 {
|
||||
// Singleton instance
|
||||
static IdCardVerificationController get instance => Get.find();
|
||||
|
||||
// Static form key
|
||||
final GlobalKey<FormState> formKey = TGlobalFormKey.idCardVerification();
|
||||
// final GlobalKey<FormState> formKey = TGlobalFormKey.idCardVerification();
|
||||
final AzureOCRService _ocrService = AzureOCRService();
|
||||
final bool isOfficer;
|
||||
|
||||
|
@ -26,6 +26,7 @@ class IdCardVerificationController extends GetxController {
|
|||
final RxBool isVerifying = RxBool(false);
|
||||
final RxBool isIdCardValid = RxBool(false);
|
||||
final RxString idCardValidationMessage = RxString('');
|
||||
final RxBool isFormValid = RxBool(true);
|
||||
|
||||
// Loading states for image uploading
|
||||
final RxBool isUploadingIdCard = RxBool(false);
|
||||
|
@ -33,25 +34,30 @@ class IdCardVerificationController extends GetxController {
|
|||
// Confirmation status
|
||||
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() {
|
||||
clearErrors();
|
||||
|
||||
// For this step, we just need to ensure ID card is uploaded and validated
|
||||
bool isValid = true;
|
||||
|
||||
if (idCardImage.value == null) {
|
||||
final idCardType = isOfficer ? 'KTA' : 'KTP';
|
||||
idCardError.value = 'Please upload your $idCardType image';
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
} else if (!isIdCardValid.value) {
|
||||
idCardError.value = 'Your ID card image is not valid';
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
} else if (!hasConfirmedIdCard.value) {
|
||||
idCardError.value = 'Please confirm your ID card image';
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
return isFormValid.value;
|
||||
}
|
||||
|
||||
void clearErrors() {
|
||||
|
@ -105,6 +111,14 @@ class IdCardVerificationController extends GetxController {
|
|||
// Clear previous validation messages
|
||||
clearErrors();
|
||||
|
||||
// Also clear previous extraction results
|
||||
extractedInfo.clear();
|
||||
hasExtractedInfo.value = false;
|
||||
|
||||
// Reset models
|
||||
ktpModel.value = null;
|
||||
ktaModel.value = null;
|
||||
|
||||
if (idCardImage.value == null) {
|
||||
idCardError.value = 'Please upload an ID card image first';
|
||||
isIdCardValid.value = false;
|
||||
|
@ -125,6 +139,40 @@ class IdCardVerificationController extends GetxController {
|
|||
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
|
||||
isImageValid = result.isNotEmpty;
|
||||
|
||||
|
@ -182,6 +230,10 @@ class IdCardVerificationController extends GetxController {
|
|||
isIdCardValid.value = false;
|
||||
idCardValidationMessage.value = '';
|
||||
hasConfirmedIdCard.value = false;
|
||||
extractedInfo.clear();
|
||||
hasExtractedInfo.value = false;
|
||||
ktpModel.value = null;
|
||||
ktaModel.value = null;
|
||||
}
|
||||
|
||||
// Confirm ID Card Image
|
||||
|
|
|
@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
|||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/image_verification_controller.dart';
|
||||
import 'package:sigap/src/utils/constants/form_key.dart';
|
||||
import 'package:sigap/src/utils/validators/validation.dart';
|
||||
|
||||
class IdentityVerificationController extends GetxController {
|
||||
|
@ -10,9 +9,10 @@ class IdentityVerificationController extends GetxController {
|
|||
static IdentityVerificationController get instance => Get.find();
|
||||
|
||||
// Static form key
|
||||
final GlobalKey<FormState> formKey = TGlobalFormKey.identityVerification();
|
||||
// final GlobalKey<FormState> formKey = TGlobalFormKey.identityVerification();
|
||||
final AzureOCRService _ocrService = AzureOCRService();
|
||||
final bool isOfficer;
|
||||
final RxBool isFormValid = RxBool(true);
|
||||
|
||||
IdentityVerificationController({required this.isOfficer});
|
||||
|
||||
|
@ -63,7 +63,7 @@ class IdentityVerificationController extends GetxController {
|
|||
}
|
||||
}
|
||||
|
||||
bool validate() {
|
||||
bool validate(GlobalKey<FormState> formKey) {
|
||||
clearErrors();
|
||||
|
||||
if (formKey.currentState?.validate() ?? false) {
|
||||
|
@ -81,9 +81,6 @@ class IdentityVerificationController extends GetxController {
|
|||
return true;
|
||||
}
|
||||
|
||||
// Manual validation as fallback
|
||||
bool isValid = true;
|
||||
|
||||
if (!isOfficer) {
|
||||
final nikValidation = TValidators.validateUserInput(
|
||||
'NIK',
|
||||
|
@ -92,7 +89,7 @@ class IdentityVerificationController extends GetxController {
|
|||
);
|
||||
if (nikValidation != null) {
|
||||
nikError.value = nikValidation;
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
// Validate full name
|
||||
|
@ -103,7 +100,7 @@ class IdentityVerificationController extends GetxController {
|
|||
);
|
||||
if (fullNameValidation != null) {
|
||||
fullNameError.value = fullNameValidation;
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
// Validate place of birth
|
||||
|
@ -114,13 +111,13 @@ class IdentityVerificationController extends GetxController {
|
|||
);
|
||||
if (placeOfBirthValidation != null) {
|
||||
placeOfBirthError.value = placeOfBirthValidation;
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
// Validate gender
|
||||
if (selectedGender.value.isEmpty) {
|
||||
genderError.value = 'Gender is required';
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
// Validate address
|
||||
|
@ -131,7 +128,7 @@ class IdentityVerificationController extends GetxController {
|
|||
);
|
||||
if (addressValidation != null) {
|
||||
addressError.value = addressValidation;
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -144,7 +141,7 @@ class IdentityVerificationController extends GetxController {
|
|||
);
|
||||
if (bioValidation != null) {
|
||||
bioError.value = bioValidation;
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
// Birth date validation
|
||||
|
@ -155,10 +152,10 @@ class IdentityVerificationController extends GetxController {
|
|||
);
|
||||
if (birthDateValidation != null) {
|
||||
birthDateError.value = birthDateValidation;
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
return isValid && isVerified.value && isFaceVerified.value;
|
||||
return isFormValid.value && isVerified.value && isFaceVerified.value;
|
||||
}
|
||||
|
||||
void clearErrors() {
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
|
||||
|
@ -7,9 +6,10 @@ class ImageVerificationController extends GetxController {
|
|||
// Singleton instance
|
||||
static ImageVerificationController get instance => Get.find();
|
||||
|
||||
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||
// final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||
final AzureOCRService _ocrService = AzureOCRService();
|
||||
final bool isOfficer;
|
||||
final RxBool isFormValid = RxBool(true);
|
||||
|
||||
ImageVerificationController({required this.isOfficer});
|
||||
|
||||
|
@ -41,32 +41,32 @@ class ImageVerificationController extends GetxController {
|
|||
clearErrors();
|
||||
|
||||
// For this step, we just need to ensure both images are uploaded and initially validated
|
||||
bool isValid = true;
|
||||
|
||||
|
||||
if (idCardImage.value == null) {
|
||||
final idCardType = isOfficer ? 'KTA' : 'KTP';
|
||||
idCardError.value = 'Please upload your $idCardType image';
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
} else if (!isIdCardValid.value) {
|
||||
idCardError.value = 'Your ID card image is not valid';
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
} else if (!hasConfirmedIdCard.value) {
|
||||
idCardError.value = 'Please confirm your ID card image';
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
if (selfieImage.value == null) {
|
||||
selfieError.value = 'Please take a selfie for verification';
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
} else if (!isSelfieValid.value) {
|
||||
selfieError.value = 'Your selfie image is not valid';
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
} else if (!hasConfirmedSelfie.value) {
|
||||
selfieError.value = 'Please confirm your selfie image';
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
return isFormValid.value;
|
||||
}
|
||||
|
||||
void clearErrors() {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/utils/constants/form_key.dart';
|
||||
import 'package:sigap/src/utils/validators/validation.dart';
|
||||
|
||||
class OfficerInfoController extends GetxController {
|
||||
|
@ -8,8 +7,9 @@ class OfficerInfoController extends GetxController {
|
|||
static OfficerInfoController get instance => Get.find();
|
||||
|
||||
// Static form key
|
||||
final GlobalKey<FormState> formKey = TGlobalFormKey.officerInfo();
|
||||
// final GlobalKey<FormState> formKey = TGlobalFormKey.officerInfo();
|
||||
|
||||
final RxBool isFormValid = RxBool(true);
|
||||
// Controllers
|
||||
final nrpController = TextEditingController();
|
||||
final rankController = TextEditingController();
|
||||
|
@ -18,15 +18,14 @@ class OfficerInfoController extends GetxController {
|
|||
final RxString nrpError = ''.obs;
|
||||
final RxString rankError = ''.obs;
|
||||
|
||||
bool validate() {
|
||||
bool validate(GlobalKey<FormState> formKey) {
|
||||
clearErrors();
|
||||
|
||||
if (formKey.currentState?.validate() ?? false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Manual validation as fallback
|
||||
bool isValid = true;
|
||||
|
||||
|
||||
final nrpValidation = TValidators.validateUserInput(
|
||||
'NRP',
|
||||
|
@ -35,7 +34,7 @@ class OfficerInfoController extends GetxController {
|
|||
);
|
||||
if (nrpValidation != null) {
|
||||
nrpError.value = nrpValidation;
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
final rankValidation = TValidators.validateUserInput(
|
||||
|
@ -45,10 +44,10 @@ class OfficerInfoController extends GetxController {
|
|||
);
|
||||
if (rankValidation != null) {
|
||||
rankError.value = rankValidation;
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
return isFormValid.value;
|
||||
}
|
||||
|
||||
void clearErrors() {
|
||||
|
|
|
@ -1,15 +1,12 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/utils/constants/form_key.dart';
|
||||
import 'package:sigap/src/utils/validators/validation.dart';
|
||||
|
||||
class PersonalInfoController extends GetxController {
|
||||
|
||||
// Singleton instance
|
||||
static PersonalInfoController get instance => Get.find();
|
||||
|
||||
// Static form key
|
||||
final GlobalKey<FormState> formKey = TGlobalFormKey.personalInfo();
|
||||
|
||||
// Controllers
|
||||
final firstNameController = TextEditingController();
|
||||
final lastNameController = TextEditingController();
|
||||
|
@ -27,6 +24,9 @@ class PersonalInfoController extends GetxController {
|
|||
final RxString addressError = ''.obs;
|
||||
|
||||
|
||||
// Manual validation as fallback
|
||||
final RxBool isFormValid = RxBool(true);
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
|
@ -51,15 +51,13 @@ class PersonalInfoController extends GetxController {
|
|||
}
|
||||
}
|
||||
|
||||
bool validate() {
|
||||
bool validate(GlobalKey<FormState> formKey) {
|
||||
clearErrors();
|
||||
|
||||
if (formKey.currentState?.validate() ?? false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Manual validation as fallback
|
||||
bool isValid = true;
|
||||
|
||||
final firstNameValidation = TValidators.validateUserInput(
|
||||
'First name',
|
||||
|
@ -68,7 +66,7 @@ class PersonalInfoController extends GetxController {
|
|||
);
|
||||
if (firstNameValidation != null) {
|
||||
firstNameError.value = firstNameValidation;
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
final lastNameValidation = TValidators.validateUserInput(
|
||||
|
@ -79,7 +77,7 @@ class PersonalInfoController extends GetxController {
|
|||
);
|
||||
if (lastNameValidation != null) {
|
||||
lastNameError.value = lastNameValidation;
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
final phoneValidation = TValidators.validatePhoneNumber(
|
||||
|
@ -87,7 +85,7 @@ class PersonalInfoController extends GetxController {
|
|||
);
|
||||
if (phoneValidation != null) {
|
||||
phoneError.value = phoneValidation;
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
// Bio can be optional, so we validate with required: false
|
||||
|
@ -99,7 +97,7 @@ class PersonalInfoController extends GetxController {
|
|||
);
|
||||
if (bioValidation != null) {
|
||||
bioError.value = bioValidation;
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
final addressValidation = TValidators.validateUserInput(
|
||||
|
@ -109,10 +107,10 @@ class PersonalInfoController extends GetxController {
|
|||
);
|
||||
if (addressValidation != null) {
|
||||
addressError.value = addressValidation;
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
return isFormValid.value;
|
||||
}
|
||||
|
||||
void clearErrors() {
|
||||
|
|
|
@ -4,19 +4,22 @@ import 'package:flutter/material.dart';
|
|||
import 'package:get/get.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
|
||||
import 'package:sigap/src/utils/constants/form_key.dart';
|
||||
|
||||
class SelfieVerificationController extends GetxController {
|
||||
// Singleton instance
|
||||
static SelfieVerificationController get instance => Get.find();
|
||||
|
||||
// Static form key
|
||||
final GlobalKey<FormState> formKey = TGlobalFormKey.selfieVerification();
|
||||
// final GlobalKey<FormState> formKey = TGlobalFormKey.selfieVerification();
|
||||
final AzureOCRService _ocrService = AzureOCRService();
|
||||
|
||||
// Maximum allowed file size in bytes (4MB)
|
||||
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
|
||||
final Rx<XFile?> selfieImage = Rx<XFile?>(null);
|
||||
final RxString selfieError = RxString('');
|
||||
|
@ -32,24 +35,22 @@ class SelfieVerificationController extends GetxController {
|
|||
// Confirmation status
|
||||
final RxBool hasConfirmedSelfie = RxBool(false);
|
||||
|
||||
bool validate() {
|
||||
bool validate(GlobalKey<FormState> formKey) {
|
||||
clearErrors();
|
||||
|
||||
// For this step, we just need to ensure selfie is uploaded and validated
|
||||
bool isValid = true;
|
||||
|
||||
if (selfieImage.value == null) {
|
||||
selfieError.value = 'Please take a selfie for verification';
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
} else if (!isSelfieValid.value) {
|
||||
selfieError.value = 'Your selfie image is not valid';
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
} else if (!hasConfirmedSelfie.value) {
|
||||
selfieError.value = 'Please confirm your selfie image';
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
return isFormValid.value;
|
||||
}
|
||||
|
||||
void clearErrors() {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/daily-ops/data/models/index.dart';
|
||||
import 'package:sigap/src/utils/constants/form_key.dart';
|
||||
import 'package:sigap/src/utils/validators/validation.dart';
|
||||
|
||||
class UnitInfoController extends GetxController {
|
||||
|
@ -9,8 +8,9 @@ class UnitInfoController extends GetxController {
|
|||
static UnitInfoController get instance => Get.find();
|
||||
|
||||
// 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
|
||||
final positionController = TextEditingController();
|
||||
final unitIdController = TextEditingController();
|
||||
|
@ -22,15 +22,14 @@ class UnitInfoController extends GetxController {
|
|||
final RxString positionError = ''.obs;
|
||||
final RxString unitIdError = ''.obs;
|
||||
|
||||
bool validate() {
|
||||
bool validate(GlobalKey<FormState> formKey) {
|
||||
clearErrors();
|
||||
|
||||
if (formKey.currentState?.validate() ?? false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Manual validation as fallback
|
||||
bool isValid = true;
|
||||
|
||||
|
||||
final positionValidation = TValidators.validateUserInput(
|
||||
'Position',
|
||||
|
@ -39,15 +38,15 @@ class UnitInfoController extends GetxController {
|
|||
);
|
||||
if (positionValidation != null) {
|
||||
positionError.value = positionValidation;
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
if (unitIdController.text.isEmpty) {
|
||||
unitIdError.value = 'Please select a unit';
|
||||
isValid = false;
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
return isFormValid.value;
|
||||
}
|
||||
|
||||
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: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/shared/widgets/form/form_section_header.dart';
|
||||
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
||||
import 'package:sigap/src/utils/constants/colors.dart';
|
||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||
import 'package:sigap/src/utils/validators/validation.dart';
|
||||
|
||||
class OfficerInfoStep extends StatelessWidget {
|
||||
|
@ -11,30 +11,20 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final formKey = GlobalKey<FormState>();
|
||||
final controller = Get.find<OfficerInfoController>();
|
||||
final mainController = Get.find<FormRegistrationController>();
|
||||
mainController.formKey = formKey;
|
||||
|
||||
return Form(
|
||||
key: controller.formKey,
|
||||
key: formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Officer Information',
|
||||
style: TextStyle(
|
||||
fontSize: TSizes.fontSizeLg,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: TColors.textPrimary,
|
||||
const FormSectionHeader(
|
||||
title: 'Officer Information',
|
||||
subtitle: 'Please provide your officer details',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TSizes.sm),
|
||||
Text(
|
||||
'Please provide your officer details',
|
||||
style: TextStyle(
|
||||
fontSize: TSizes.fontSizeSm,
|
||||
color: TColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TSizes.spaceBtwItems),
|
||||
|
||||
// NRP field
|
||||
Obx(
|
|
@ -1,9 +1,9 @@
|
|||
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/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/utils/constants/colors.dart';
|
||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||
import 'package:sigap/src/utils/validators/validation.dart';
|
||||
|
||||
class PersonalInfoStep extends StatelessWidget {
|
||||
|
@ -11,30 +11,20 @@ class PersonalInfoStep extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final formKey = GlobalKey<FormState>();
|
||||
final controller = Get.find<PersonalInfoController>();
|
||||
final mainController = Get.find<FormRegistrationController>();
|
||||
mainController.formKey = formKey;
|
||||
|
||||
return Form(
|
||||
key: controller.formKey,
|
||||
key: formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Personal Information',
|
||||
style: TextStyle(
|
||||
fontSize: TSizes.fontSizeLg,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: TColors.textPrimary,
|
||||
const FormSectionHeader(
|
||||
title: 'Personal Information',
|
||||
subtitle: 'Please provide your personal details',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TSizes.sm),
|
||||
Text(
|
||||
'Please provide your personal details',
|
||||
style: TextStyle(
|
||||
fontSize: TSizes.fontSizeSm,
|
||||
color: TColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TSizes.spaceBtwItems),
|
||||
|
||||
// First Name field
|
||||
Obx(
|
|
@ -2,12 +2,12 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/id_card_verification_step.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification_step.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/officer_info_step.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/personal_info_step.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/selfie_verification_step.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/unit_info_step.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/id_card_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/officer_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/selfie_verification_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/shared/widgets/indicators/step_indicator/step_indicator.dart';
|
||||
import 'package:sigap/src/utils/constants/colors.dart';
|
||||
|
@ -19,7 +19,6 @@ class FormRegistrationScreen extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Get the controller
|
||||
final controller = Get.find<FormRegistrationController>();
|
||||
final dark = THelperFunctions.isDarkMode(context);
|
||||
|
||||
|
@ -33,28 +32,9 @@ class FormRegistrationScreen extends StatelessWidget {
|
|||
|
||||
return Scaffold(
|
||||
backgroundColor: dark ? TColors.dark : TColors.light,
|
||||
appBar: 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(),
|
||||
),
|
||||
),
|
||||
appBar: _buildAppBar(context, dark),
|
||||
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 &&
|
||||
controller.userMetadata.value.roleId == null) {
|
||||
return const Center(
|
||||
|
@ -73,18 +53,7 @@ class FormRegistrationScreen extends StatelessWidget {
|
|||
child: Column(
|
||||
children: [
|
||||
// Step indicator
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildStepIndicator(controller),
|
||||
|
||||
// Step content
|
||||
Expanded(
|
||||
|
@ -99,7 +68,54 @@ class FormRegistrationScreen extends StatelessWidget {
|
|||
),
|
||||
|
||||
// Navigation buttons
|
||||
Padding(
|
||||
_buildNavigationButtons(controller),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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: [
|
||||
|
@ -109,9 +125,7 @@ class FormRegistrationScreen extends StatelessWidget {
|
|||
controller.currentStep.value > 0
|
||||
? Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: TSizes.sm,
|
||||
),
|
||||
padding: const EdgeInsets.only(right: TSizes.sm),
|
||||
child: AuthButton(
|
||||
text: 'Previous',
|
||||
onPressed: controller.previousStep,
|
||||
|
@ -126,16 +140,12 @@ class FormRegistrationScreen extends StatelessWidget {
|
|||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left:
|
||||
controller.currentStep.value > 0
|
||||
? TSizes.sm
|
||||
: 0.0,
|
||||
left: controller.currentStep.value > 0 ? TSizes.sm : 0.0,
|
||||
),
|
||||
child: Obx(
|
||||
() => AuthButton(
|
||||
text:
|
||||
controller.currentStep.value ==
|
||||
controller.totalSteps - 1
|
||||
controller.currentStep.value == controller.totalSteps - 1
|
||||
? 'Submit'
|
||||
: 'Next',
|
||||
onPressed: controller.nextStep,
|
||||
|
@ -146,11 +156,6 @@ class FormRegistrationScreen extends StatelessWidget {
|
|||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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: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/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/utils/constants/colors.dart';
|
||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||
import 'package:sigap/src/utils/validators/validation.dart';
|
||||
|
||||
class UnitInfoStep extends StatelessWidget {
|
||||
|
@ -12,30 +12,20 @@ class UnitInfoStep extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final formKey = GlobalKey<FormState>();
|
||||
final controller = Get.find<UnitInfoController>();
|
||||
final mainController = Get.find<FormRegistrationController>();
|
||||
mainController.formKey = formKey;
|
||||
|
||||
return Form(
|
||||
key: controller.formKey,
|
||||
key: formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Unit Information',
|
||||
style: TextStyle(
|
||||
fontSize: TSizes.fontSizeLg,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: TColors.textPrimary,
|
||||
const FormSectionHeader(
|
||||
title: 'Unit Information',
|
||||
subtitle: 'Please provide your unit details',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TSizes.sm),
|
||||
Text(
|
||||
'Please provide your unit details',
|
||||
style: TextStyle(
|
||||
fontSize: TSizes.fontSizeSm,
|
||||
color: TColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TSizes.spaceBtwItems),
|
||||
|
||||
// Position field
|
||||
Obx(
|
|
@ -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
|
||||
Widget build(BuildContext context) {
|
||||
// Init form key
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
// Get the controller
|
||||
final controller = Get.find<SignInController>();
|
||||
|
||||
|
@ -35,7 +38,7 @@ class SignInScreen extends StatelessWidget {
|
|||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Form(
|
||||
key: controller.signinFormKey,
|
||||
key: formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
@ -92,7 +95,7 @@ class SignInScreen extends StatelessWidget {
|
|||
Obx(
|
||||
() => AuthButton(
|
||||
text: 'Sign In',
|
||||
onPressed: controller.credentialsSignIn,
|
||||
onPressed: () => controller.credentialsSignIn(formKey),
|
||||
isLoading: controller.isLoading.value,
|
||||
),
|
||||
),
|
||||
|
@ -112,7 +115,7 @@ class SignInScreen extends StatelessWidget {
|
|||
color: TColors.light,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => controller.googleSignIn(),
|
||||
onPressed: () => controller.googleSignIn(formKey),
|
||||
),
|
||||
|
||||
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:get/get.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/features/onboarding/data/dummy/onboarding_dummy.dart';
|
||||
import 'package:sigap/src/utils/constants/app_routes.dart';
|
||||
|
@ -14,7 +15,7 @@ class OnboardingController extends GetxController
|
|||
static OnboardingController get instance => Get.find();
|
||||
|
||||
// Storage for onboarding state
|
||||
final _storage = GetStorage();
|
||||
final storage = GetStorage();
|
||||
|
||||
// Location service
|
||||
final _locationService = Get.find<LocationService>();
|
||||
|
@ -70,7 +71,7 @@ class OnboardingController extends GetxController
|
|||
// Method to go to next page
|
||||
void nextPage() {
|
||||
if (currentIndex.value == contents.length - 1) {
|
||||
navigateToWelcomeScreen();
|
||||
skipOnboarding();
|
||||
} else {
|
||||
pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
|
@ -81,13 +82,13 @@ class OnboardingController extends GetxController
|
|||
|
||||
// Method to skip to welcome screen
|
||||
void skipToWelcomeScreen() {
|
||||
navigateToWelcomeScreen();
|
||||
skipOnboarding();
|
||||
}
|
||||
|
||||
// Method to navigate to welcome screen
|
||||
void navigateToWelcomeScreen() {
|
||||
void skipOnboarding() {
|
||||
// Mark onboarding as completed in storage
|
||||
_storage.write('ONBOARDING_COMPLETED', true);
|
||||
storage.write('isFirstTime', true);
|
||||
Get.offAllNamed(AppRoutes.welcome);
|
||||
}
|
||||
|
||||
|
@ -108,8 +109,15 @@ class OnboardingController extends GetxController
|
|||
|
||||
TFullScreenLoader.stopLoading();
|
||||
|
||||
Logger().i('isFirstTime before: ${storage.read('isFirstTime')}');
|
||||
|
||||
storage.write('isFirstTime', false);
|
||||
|
||||
Logger().i('isFirstTime after: ${storage.read('isFirstTime')}');
|
||||
|
||||
if (isLocationValid) {
|
||||
// If location is valid, proceed to role selection
|
||||
|
||||
Get.offAllNamed(AppRoutes.signupWithRole);
|
||||
|
||||
// TLoaders.successSnackBar(
|
||||
|
@ -118,7 +126,6 @@ class OnboardingController extends GetxController
|
|||
// );
|
||||
|
||||
// Store isfirstTime to false in storage
|
||||
_storage.write('isFirstTime', false);
|
||||
} else {
|
||||
// If location is invalid, show warning screen
|
||||
Get.offAllNamed(AppRoutes.locationWarning);
|
||||
|
@ -141,6 +148,8 @@ class OnboardingController extends GetxController
|
|||
}
|
||||
|
||||
void goToSignIn() {
|
||||
storage.write('isFirstTime', true);
|
||||
|
||||
Get.offAllNamed(AppRoutes.signIn);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,7 +31,7 @@ class OnboardingScreen extends StatelessWidget {
|
|||
child: Padding(
|
||||
padding: const EdgeInsets.all(TSizes.md),
|
||||
child: TextButton(
|
||||
onPressed: controller.skipToWelcomeScreen,
|
||||
onPressed: controller.skipOnboarding,
|
||||
child: Text(
|
||||
'Skip',
|
||||
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
|
||||
Future<List<UserModel>> searchUsers(String query, {int limit = 20}) async {
|
||||
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),
|
||||
// ),
|
||||
// ),
|
||||
// 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"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
@ -31,6 +31,7 @@ dependencies:
|
|||
time_slot:
|
||||
calendar_date_picker2:
|
||||
easy_date_timeline:
|
||||
equatable: ^2.0.7
|
||||
|
||||
# --- Logging & Debugging ---
|
||||
logger:
|
||||
|
|
Loading…
Reference in New Issue