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:
vergiLgood1 2025-05-22 16:20:34 +07:00
parent 7f6f0c40b7
commit 6a85f75e3c
48 changed files with 7450 additions and 2914 deletions

View File

@ -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,
);
}
}

View File

@ -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;
}

View File

@ -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),
),
),
),
],
),
),
);
}
}

View File

@ -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

View File

@ -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);
}
}
}
}

View File

@ -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;

View File

@ -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

View File

@ -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;

View File

@ -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

View File

@ -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() {

View File

@ -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() {

View File

@ -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() {

View File

@ -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() {

View File

@ -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() {

View File

@ -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() {

View File

@ -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,
),
),
),
],
),
);
}
}

View File

@ -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 = '';
}
}
}

View File

@ -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
);
}
}

View File

@ -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(

View File

@ -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(

View File

@ -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 {
),
],
),
),
],
),
);
}),
);
}

View File

@ -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);
}
}

View File

@ -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(

View File

@ -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),
],
),
);
}
}

View File

@ -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;
}
}
}

View File

@ -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),
],
),
);
}
}

View File

@ -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);
}
}

View File

@ -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),

View File

@ -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)';
}
}

View File

@ -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);
}
}

View File

@ -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,

View File

@ -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)';
}
}

View File

@ -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 {

View File

@ -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;
}
}
}

View File

@ -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),
],
);
}
}

View File

@ -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),
],
);
}
}

View File

@ -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();
}
}

View File

@ -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),
],
),
);
}
}

View File

@ -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,
),
),
],
),
),
);
}
}

View File

@ -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),
),
),
),
],
),
);
}
}

View File

@ -112,7 +112,7 @@ class CustomTextField extends StatelessWidget {
// ).textTheme.bodySmall?.copyWith(color: TColors.error),
// ),
// ),
// const SizedBox(height: TSizes.spaceBtwInputFields),
const SizedBox(height: TSizes.spaceBtwInputFields),
],
);
}

View File

@ -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,
),
),
),
],
),
);
}
}

View File

@ -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,
),
),
],
),
),
],
),
);
}
}

View File

@ -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;
}
}

View File

@ -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());
}
}

View File

@ -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:

View File

@ -31,6 +31,7 @@ dependencies:
time_slot:
calendar_date_picker2:
easy_date_timeline:
equatable: ^2.0.7
# --- Logging & Debugging ---
logger: