Refactor code structure for improved readability and maintainability
This commit is contained in:
parent
c26d749026
commit
a35ba880c5
|
@ -49,3 +49,4 @@ AZURE_FACE_SUBSCRIPTION_KEY="6pBJKuYEFWHkrCBaZh8hErDci6ZwYnG0tEaE3VA34P8XPAYj4Zv
|
||||||
AWS_REGION="ap-southeast-1"
|
AWS_REGION="ap-southeast-1"
|
||||||
AWS_ACCESS_KEY="AKIAW3MD7UU5G2XTA44C"
|
AWS_ACCESS_KEY="AKIAW3MD7UU5G2XTA44C"
|
||||||
AWS_SECRET_KEY="8jgxMWWmsEUd4q/++9W+R/IOQ/IxFTAKmtnaBQKe"
|
AWS_SECRET_KEY="8jgxMWWmsEUd4q/++9W+R/IOQ/IxFTAKmtnaBQKe"
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/cores/services/background_service.dart';
|
import 'package:sigap/src/cores/services/background_service.dart';
|
||||||
import 'package:sigap/src/cores/services/biometric_service.dart';
|
import 'package:sigap/src/cores/services/biometric_service.dart';
|
||||||
|
import 'package:sigap/src/cores/services/facial_verification_service.dart';
|
||||||
import 'package:sigap/src/cores/services/location_service.dart';
|
import 'package:sigap/src/cores/services/location_service.dart';
|
||||||
import 'package:sigap/src/cores/services/supabase_service.dart';
|
import 'package:sigap/src/cores/services/supabase_service.dart';
|
||||||
|
|
||||||
|
@ -19,5 +20,8 @@ class ServiceBindings extends Bindings {
|
||||||
await Get.putAsync(() => SupabaseService().init(), permanent: true);
|
await Get.putAsync(() => SupabaseService().init(), permanent: true);
|
||||||
await Get.putAsync(() => biometricService.init(), permanent: true);
|
await Get.putAsync(() => biometricService.init(), permanent: true);
|
||||||
await Get.putAsync(() => locationService.init(), permanent: true);
|
await Get.putAsync(() => locationService.init(), permanent: true);
|
||||||
|
Get.putAsync<FacialVerificationService>(
|
||||||
|
() async => FacialVerificationService.instance,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,214 +1,214 @@
|
||||||
import 'dart:convert';
|
// import 'dart:convert';
|
||||||
import 'dart:io';
|
// import 'dart:io';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
// import 'package:dio/dio.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
// import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:sigap/src/features/auth/data/models/face_model.dart';
|
// import 'package:sigap/src/features/auth/data/models/face_model.dart';
|
||||||
import 'package:sigap/src/utils/constants/api_urls.dart';
|
// import 'package:sigap/src/utils/constants/api_urls.dart';
|
||||||
import 'package:sigap/src/utils/dio.client/dio_client.dart';
|
// import 'package:sigap/src/utils/dio.client/dio_client.dart';
|
||||||
import 'package:sigap/src/utils/helpers/aws_signature.dart';
|
// import 'package:sigap/src/utils/helpers/aws_signature.dart';
|
||||||
|
|
||||||
class AwsRecognitionService {
|
// class AwsRecognitionService {
|
||||||
// Singleton instance
|
// // Singleton instance
|
||||||
static final AwsRecognitionService instance = AwsRecognitionService._();
|
// static final AwsRecognitionService instance = AwsRecognitionService._();
|
||||||
AwsRecognitionService._();
|
// AwsRecognitionService._();
|
||||||
|
|
||||||
// AWS Recognition API configuration
|
// // AWS Recognition API configuration
|
||||||
final String region = Endpoints.awsRegion;
|
// final String region = Endpoints.awsRegion;
|
||||||
final String accessKey = Endpoints.awsAccessKey;
|
// final String accessKey = Endpoints.awsAccessKey;
|
||||||
final String secretKey = Endpoints.awsSecretKey;
|
// final String secretKey = Endpoints.awsSecretKey;
|
||||||
final String serviceEndpoint = Endpoints.awsRekognitionEndpoint;
|
// final String serviceEndpoint = Endpoints.awsRekognitionEndpoint;
|
||||||
final String serviceName = 'rekognition';
|
// final String serviceName = 'rekognition';
|
||||||
|
|
||||||
// Face detection threshold values
|
// // Face detection threshold values
|
||||||
final double faceMatchThreshold =
|
// final double faceMatchThreshold =
|
||||||
80.0; // Minimum confidence for face match (0-100)
|
// 80.0; // Minimum confidence for face match (0-100)
|
||||||
|
|
||||||
// Detect faces in an image and return face details
|
// // Detect faces in an image and return face details
|
||||||
Future<List<FaceModel>> detectFaces(XFile imageFile) async {
|
// Future<List<FaceModel>> detectFaces(XFile imageFile) async {
|
||||||
try {
|
// try {
|
||||||
final bytes = await File(imageFile.path).readAsBytes();
|
// final bytes = await File(imageFile.path).readAsBytes();
|
||||||
final base64Image = base64Encode(bytes);
|
// final base64Image = base64Encode(bytes);
|
||||||
|
|
||||||
// Create AWS Signature
|
// // Create AWS Signature
|
||||||
final awsSignature = AwsSignature(
|
// final awsSignature = AwsSignature(
|
||||||
accessKey: accessKey,
|
// accessKey: accessKey,
|
||||||
secretKey: secretKey,
|
// secretKey: secretKey,
|
||||||
region: region,
|
// region: region,
|
||||||
serviceName: serviceName,
|
// serviceName: serviceName,
|
||||||
);
|
// );
|
||||||
|
|
||||||
// Prepare request payload
|
// // Prepare request payload
|
||||||
final payload = {
|
// final payload = {
|
||||||
'Image': {'Bytes': base64Image},
|
// 'Image': {'Bytes': base64Image},
|
||||||
'Attributes': ['DEFAULT'],
|
// 'Attributes': ['DEFAULT'],
|
||||||
};
|
// };
|
||||||
|
|
||||||
// Get signed headers and URL
|
// // Get signed headers and URL
|
||||||
final dateTime = DateTime.now().toUtc();
|
// final dateTime = DateTime.now().toUtc();
|
||||||
final uri = Uri.parse('$serviceEndpoint/DetectFaces');
|
// final uri = Uri.parse('$serviceEndpoint/DetectFaces');
|
||||||
final headers = awsSignature.buildRequestHeaders(
|
// final headers = awsSignature.buildRequestHeaders(
|
||||||
method: 'POST',
|
// method: 'POST',
|
||||||
uri: uri,
|
// uri: uri,
|
||||||
payload: payload,
|
// payload: payload,
|
||||||
dateTime: dateTime,
|
// dateTime: dateTime,
|
||||||
);
|
// );
|
||||||
|
|
||||||
// Make API request
|
// // Make API request
|
||||||
final response = await DioClient().post(
|
// final response = await DioClient().post(
|
||||||
uri.toString(),
|
// uri.toString(),
|
||||||
data: payload,
|
// data: payload,
|
||||||
options: Options(headers: headers, responseType: ResponseType.json),
|
// options: Options(headers: headers, responseType: ResponseType.json),
|
||||||
);
|
// );
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
// if (response.statusCode == 200) {
|
||||||
final faceDetails = response.data['FaceDetails'];
|
// final faceDetails = response.data['FaceDetails'];
|
||||||
// Convert AWS response to FaceModel objects
|
// // Convert AWS response to FaceModel objects
|
||||||
List<FaceModel> faces = [];
|
// List<FaceModel> faces = [];
|
||||||
for (var i = 0; i < faceDetails.length; i++) {
|
// for (var i = 0; i < faceDetails.length; i++) {
|
||||||
String faceId = 'face_${dateTime.millisecondsSinceEpoch}_$i';
|
// String faceId = 'face_${dateTime.millisecondsSinceEpoch}_$i';
|
||||||
faces.add(FaceModel.fromDetection(faceId, imageFile, faceDetails[i]));
|
// faces.add(FaceModel.fromDetection(faceId, imageFile, faceDetails[i]));
|
||||||
}
|
// }
|
||||||
return faces;
|
// return faces;
|
||||||
} else {
|
// } else {
|
||||||
throw Exception(
|
// throw Exception(
|
||||||
'Failed to detect faces: ${response.statusCode} - ${response.data}',
|
// 'Failed to detect faces: ${response.statusCode} - ${response.data}',
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
} catch (e) {
|
// } catch (e) {
|
||||||
print('Face detection error: $e');
|
// print('Face detection error: $e');
|
||||||
return [];
|
// return [];
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Compare two face images and return comparison result
|
// // Compare two face images and return comparison result
|
||||||
Future<FaceComparisonResult> compareFaces(
|
// Future<FaceComparisonResult> compareFaces(
|
||||||
XFile sourceImage,
|
// XFile sourceImage,
|
||||||
XFile targetImage,
|
// XFile targetImage,
|
||||||
) async {
|
// ) async {
|
||||||
try {
|
// try {
|
||||||
// First detect faces in both images
|
// // First detect faces in both images
|
||||||
List<FaceModel> sourceFaces = await detectFaces(sourceImage);
|
// List<FaceModel> sourceFaces = await detectFaces(sourceImage);
|
||||||
List<FaceModel> targetFaces = await detectFaces(targetImage);
|
// List<FaceModel> targetFaces = await detectFaces(targetImage);
|
||||||
|
|
||||||
if (sourceFaces.isEmpty || targetFaces.isEmpty) {
|
// if (sourceFaces.isEmpty || targetFaces.isEmpty) {
|
||||||
return FaceComparisonResult.noMatch(
|
// return FaceComparisonResult.noMatch(
|
||||||
sourceFaces.isEmpty ? FaceModel.empty() : sourceFaces.first,
|
// sourceFaces.isEmpty ? FaceModel.empty() : sourceFaces.first,
|
||||||
targetFaces.isEmpty ? FaceModel.empty() : targetFaces.first,
|
// targetFaces.isEmpty ? FaceModel.empty() : targetFaces.first,
|
||||||
message:
|
// message:
|
||||||
sourceFaces.isEmpty && targetFaces.isEmpty
|
// sourceFaces.isEmpty && targetFaces.isEmpty
|
||||||
? 'No faces detected in either image'
|
// ? 'No faces detected in either image'
|
||||||
: sourceFaces.isEmpty
|
// : sourceFaces.isEmpty
|
||||||
? 'No face detected in ID card image'
|
// ? 'No face detected in ID card image'
|
||||||
: 'No face detected in selfie image',
|
// : 'No face detected in selfie image',
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Get the primary faces from each image
|
// // Get the primary faces from each image
|
||||||
FaceModel sourceFace = sourceFaces.first;
|
// FaceModel sourceFace = sourceFaces.first;
|
||||||
FaceModel targetFace = targetFaces.first;
|
// FaceModel targetFace = targetFaces.first;
|
||||||
|
|
||||||
final sourceBytes = await File(sourceImage.path).readAsBytes();
|
// final sourceBytes = await File(sourceImage.path).readAsBytes();
|
||||||
final targetBytes = await File(targetImage.path).readAsBytes();
|
// final targetBytes = await File(targetImage.path).readAsBytes();
|
||||||
|
|
||||||
// Create AWS Signature
|
// // Create AWS Signature
|
||||||
final awsSignature = AwsSignature(
|
// final awsSignature = AwsSignature(
|
||||||
accessKey: accessKey,
|
// accessKey: accessKey,
|
||||||
secretKey: secretKey,
|
// secretKey: secretKey,
|
||||||
region: region,
|
// region: region,
|
||||||
serviceName: serviceName,
|
// serviceName: serviceName,
|
||||||
);
|
// );
|
||||||
|
|
||||||
// Prepare request payload
|
// // Prepare request payload
|
||||||
final payload = {
|
// final payload = {
|
||||||
'SourceImage': {'Bytes': base64Encode(sourceBytes)},
|
// 'SourceImage': {'Bytes': base64Encode(sourceBytes)},
|
||||||
'TargetImage': {'Bytes': base64Encode(targetBytes)},
|
// 'TargetImage': {'Bytes': base64Encode(targetBytes)},
|
||||||
'SimilarityThreshold': faceMatchThreshold,
|
// 'SimilarityThreshold': faceMatchThreshold,
|
||||||
};
|
// };
|
||||||
|
|
||||||
// Get signed headers and URL
|
// // Get signed headers and URL
|
||||||
final dateTime = DateTime.now().toUtc();
|
// final dateTime = DateTime.now().toUtc();
|
||||||
final uri = Uri.parse('$serviceEndpoint/CompareFaces');
|
// final uri = Uri.parse('$serviceEndpoint/CompareFaces');
|
||||||
final headers = awsSignature.buildRequestHeaders(
|
// final headers = awsSignature.buildRequestHeaders(
|
||||||
method: 'POST',
|
// method: 'POST',
|
||||||
uri: uri,
|
// uri: uri,
|
||||||
payload: payload,
|
// payload: payload,
|
||||||
dateTime: dateTime,
|
// dateTime: dateTime,
|
||||||
);
|
// );
|
||||||
|
|
||||||
// Make API request
|
// // Make API request
|
||||||
final response = await DioClient().post(
|
// final response = await DioClient().post(
|
||||||
uri.toString(),
|
// uri.toString(),
|
||||||
data: payload,
|
// data: payload,
|
||||||
options: Options(headers: headers, responseType: ResponseType.json),
|
// options: Options(headers: headers, responseType: ResponseType.json),
|
||||||
);
|
// );
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
// if (response.statusCode == 200) {
|
||||||
return FaceComparisonResult.fromAwsResponse(
|
// return FaceComparisonResult.fromAwsResponse(
|
||||||
sourceFace,
|
// sourceFace,
|
||||||
targetFace,
|
// targetFace,
|
||||||
response.data,
|
// response.data,
|
||||||
);
|
// );
|
||||||
} else {
|
// } else {
|
||||||
throw Exception(
|
// throw Exception(
|
||||||
'Failed to compare faces: ${response.statusCode} - ${response.data}',
|
// 'Failed to compare faces: ${response.statusCode} - ${response.data}',
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
} catch (e) {
|
// } catch (e) {
|
||||||
print('Face comparison error: $e');
|
// print('Face comparison error: $e');
|
||||||
return FaceComparisonResult.error(
|
// return FaceComparisonResult.error(
|
||||||
FaceModel.empty().withMessage('Source face processing error'),
|
// FaceModel.empty().withMessage('Source face processing error'),
|
||||||
FaceModel.empty().withMessage('Target face processing error'),
|
// FaceModel.empty().withMessage('Target face processing error'),
|
||||||
e.toString(),
|
// e.toString(),
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Perform liveness detection (anti-spoofing check)
|
// // Perform liveness detection (anti-spoofing check)
|
||||||
Future<FaceModel> performLivenessCheck(XFile selfieImage) async {
|
// Future<FaceModel> performLivenessCheck(XFile selfieImage) async {
|
||||||
try {
|
// try {
|
||||||
// In a real implementation, AWS Recognition doesn't directly offer liveness detection
|
// // In a real implementation, AWS Recognition doesn't directly offer liveness detection
|
||||||
// You might need to use a combination of services or a third-party solution
|
// // You might need to use a combination of services or a third-party solution
|
||||||
// For now, we'll simulate a successful check by detecting a face
|
// // For now, we'll simulate a successful check by detecting a face
|
||||||
final faces = await detectFaces(selfieImage);
|
// final faces = await detectFaces(selfieImage);
|
||||||
|
|
||||||
if (faces.isEmpty) {
|
// if (faces.isEmpty) {
|
||||||
return FaceModel.empty().withLiveness(
|
// return FaceModel.empty().withLiveness(
|
||||||
isLive: false,
|
// isLive: false,
|
||||||
confidence: 0.0,
|
// confidence: 0.0,
|
||||||
message: 'No face detected in the selfie.',
|
// message: 'No face detected in the selfie.',
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Get the primary face
|
// // Get the primary face
|
||||||
FaceModel face = faces.first;
|
// FaceModel face = faces.first;
|
||||||
|
|
||||||
// Check confidence of face detection as a basic indicator
|
// // Check confidence of face detection as a basic indicator
|
||||||
if (face.detectionConfidence < 0.7) {
|
// if (face.detectionConfidence < 0.7) {
|
||||||
return face.withLiveness(
|
// return face.withLiveness(
|
||||||
isLive: false,
|
// isLive: false,
|
||||||
confidence: face.detectionConfidence,
|
// confidence: face.detectionConfidence,
|
||||||
message:
|
// message:
|
||||||
'Low confidence face detection. Please take a clearer selfie.',
|
// 'Low confidence face detection. Please take a clearer selfie.',
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
||||||
// For a full implementation, you might want to:
|
// // For a full implementation, you might want to:
|
||||||
// 1. Check eye blink detection
|
// // 1. Check eye blink detection
|
||||||
// 2. Analyze multiple facial movements
|
// // 2. Analyze multiple facial movements
|
||||||
// 3. Use depth information if available
|
// // 3. Use depth information if available
|
||||||
|
|
||||||
return face.withLiveness(
|
// return face.withLiveness(
|
||||||
isLive: true,
|
// isLive: true,
|
||||||
confidence: face.detectionConfidence,
|
// confidence: face.detectionConfidence,
|
||||||
message: 'Liveness check passed successfully.',
|
// message: 'Liveness check passed successfully.',
|
||||||
);
|
// );
|
||||||
} catch (e) {
|
// } catch (e) {
|
||||||
return FaceModel.empty().withLiveness(
|
// return FaceModel.empty().withLiveness(
|
||||||
isLive: false,
|
// isLive: false,
|
||||||
confidence: 0.0,
|
// confidence: 0.0,
|
||||||
message: 'Liveness check error: ${e.toString()}',
|
// message: 'Liveness check error: ${e.toString()}',
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
|
@ -0,0 +1,245 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:sigap/src/cores/services/supabase_service.dart';
|
||||||
|
import 'package:sigap/src/features/auth/data/models/face_model.dart';
|
||||||
|
|
||||||
|
/// Service class for interacting with Supabase Edge Functions for face recognition
|
||||||
|
class EdgeFunctionService {
|
||||||
|
// Singleton instance
|
||||||
|
static final EdgeFunctionService instance = EdgeFunctionService._();
|
||||||
|
EdgeFunctionService._();
|
||||||
|
|
||||||
|
// Supabase client for Edge Function invocation
|
||||||
|
final supabase = SupabaseService.instance.client;
|
||||||
|
|
||||||
|
// Edge function names
|
||||||
|
final String _detectFaceFunction = 'detect-face';
|
||||||
|
final String _verifyFaceFunction = 'verify-face';
|
||||||
|
|
||||||
|
// Max retries
|
||||||
|
final int _maxRetries = 0;
|
||||||
|
|
||||||
|
/// Detects faces in an image using the Supabase Edge Function with retries
|
||||||
|
Future<List<FaceModel>> detectFaces(XFile imageFile) async {
|
||||||
|
int retries = 0;
|
||||||
|
Exception? lastException;
|
||||||
|
|
||||||
|
while (retries <= _maxRetries) {
|
||||||
|
try {
|
||||||
|
// Read image as bytes and convert to base64 for sending
|
||||||
|
final bytes = await File(imageFile.path).readAsBytes();
|
||||||
|
final base64Image = base64Encode(bytes);
|
||||||
|
|
||||||
|
// Prepare request payload
|
||||||
|
final payload = {
|
||||||
|
'image': base64Image,
|
||||||
|
'options': {'detectAttributes': true, 'returnFaceId': true},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call the Supabase Edge Function
|
||||||
|
final res = await supabase.functions.invoke(
|
||||||
|
_detectFaceFunction,
|
||||||
|
body: payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Process the response
|
||||||
|
final data = res.data;
|
||||||
|
List<FaceModel> faces = [];
|
||||||
|
|
||||||
|
// Handle different response formats
|
||||||
|
if (data is Map && data.containsKey('faces') && data['faces'] is List) {
|
||||||
|
// Process list of faces
|
||||||
|
final facesList = data['faces'] as List;
|
||||||
|
for (var i = 0; i < facesList.length; i++) {
|
||||||
|
faces.add(FaceModel.fromEdgeFunction(imageFile, facesList[i]));
|
||||||
|
}
|
||||||
|
} else if (data is Map) {
|
||||||
|
// Single face response
|
||||||
|
faces.add(
|
||||||
|
FaceModel.fromEdgeFunction(imageFile, data as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return faces;
|
||||||
|
} catch (e) {
|
||||||
|
lastException = e is Exception ? e : Exception(e.toString());
|
||||||
|
retries++;
|
||||||
|
|
||||||
|
// Wait before retrying
|
||||||
|
if (retries <= _maxRetries) {
|
||||||
|
await Future.delayed(Duration(seconds: retries * 2));
|
||||||
|
print('Retrying face detection (attempt $retries)...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, all retries failed
|
||||||
|
print('Face detection failed after $_maxRetries retries: $lastException');
|
||||||
|
throw lastException ?? Exception('Face detection failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs liveness detection on a selfie using edge functions with retries
|
||||||
|
Future<FaceModel> performLivenessCheck(XFile selfieImage) async {
|
||||||
|
int retries = 0;
|
||||||
|
Exception? lastException;
|
||||||
|
|
||||||
|
while (retries <= _maxRetries) {
|
||||||
|
try {
|
||||||
|
// First detect the face
|
||||||
|
final faces = await detectFaces(selfieImage);
|
||||||
|
|
||||||
|
if (faces.isEmpty) {
|
||||||
|
return FaceModel.empty().withLiveness(
|
||||||
|
isLive: false,
|
||||||
|
confidence: 0.0,
|
||||||
|
message: 'No face detected in the selfie.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the primary face
|
||||||
|
FaceModel face = faces.first;
|
||||||
|
|
||||||
|
// Prepare liveness check payload
|
||||||
|
final bytes = await File(selfieImage.path).readAsBytes();
|
||||||
|
final base64Image = base64Encode(bytes);
|
||||||
|
|
||||||
|
final payload = {
|
||||||
|
'image': base64Image,
|
||||||
|
'options': {'performLiveness': true},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call the Supabase Edge Function
|
||||||
|
final res = await supabase.functions.invoke(
|
||||||
|
_detectFaceFunction,
|
||||||
|
body: payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Process the response
|
||||||
|
final data = res.data;
|
||||||
|
|
||||||
|
// Extract liveness information
|
||||||
|
bool isLive = data['isLive'] ?? false;
|
||||||
|
double confidence = 0.0;
|
||||||
|
|
||||||
|
if (data['livenessScore'] != null) {
|
||||||
|
// Normalize to 0-1 range
|
||||||
|
confidence =
|
||||||
|
(data['livenessScore'] is int || data['livenessScore'] > 1.0)
|
||||||
|
? data['livenessScore'] / 100.0
|
||||||
|
: data['livenessScore'];
|
||||||
|
}
|
||||||
|
|
||||||
|
String message =
|
||||||
|
data['message'] ??
|
||||||
|
(isLive
|
||||||
|
? 'Liveness check passed.'
|
||||||
|
: 'Liveness check failed. Please try again.');
|
||||||
|
|
||||||
|
return face.withLiveness(
|
||||||
|
isLive: isLive,
|
||||||
|
confidence: confidence,
|
||||||
|
message: message,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
lastException = e is Exception ? e : Exception(e.toString());
|
||||||
|
retries++;
|
||||||
|
|
||||||
|
// Wait before retrying
|
||||||
|
if (retries <= _maxRetries) {
|
||||||
|
await Future.delayed(Duration(seconds: retries * 2));
|
||||||
|
print('Retrying liveness check (attempt $retries)...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, all retries failed
|
||||||
|
print('Liveness check failed after $_maxRetries retries: $lastException');
|
||||||
|
return FaceModel.empty().withLiveness(
|
||||||
|
isLive: false,
|
||||||
|
confidence: 0.0,
|
||||||
|
message:
|
||||||
|
'Liveness check failed after multiple attempts. Please try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compares two face images and returns a comparison result with retries
|
||||||
|
Future<FaceComparisonResult> compareFaces(
|
||||||
|
XFile sourceImage,
|
||||||
|
XFile targetImage,
|
||||||
|
) async {
|
||||||
|
int retries = 0;
|
||||||
|
Exception? lastException;
|
||||||
|
|
||||||
|
while (retries <= _maxRetries) {
|
||||||
|
try {
|
||||||
|
// First detect faces in both images
|
||||||
|
List<FaceModel> sourceFaces = await detectFaces(sourceImage);
|
||||||
|
List<FaceModel> targetFaces = await detectFaces(targetImage);
|
||||||
|
|
||||||
|
if (sourceFaces.isEmpty || targetFaces.isEmpty) {
|
||||||
|
return FaceComparisonResult.noMatch(
|
||||||
|
sourceFaces.isEmpty ? FaceModel.empty() : sourceFaces.first,
|
||||||
|
targetFaces.isEmpty ? FaceModel.empty() : targetFaces.first,
|
||||||
|
message:
|
||||||
|
sourceFaces.isEmpty && targetFaces.isEmpty
|
||||||
|
? 'No faces detected in either image'
|
||||||
|
: sourceFaces.isEmpty
|
||||||
|
? 'No face detected in ID card image'
|
||||||
|
: 'No face detected in selfie image',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the primary faces from each image
|
||||||
|
FaceModel sourceFace = sourceFaces.first;
|
||||||
|
FaceModel targetFace = targetFaces.first;
|
||||||
|
|
||||||
|
// Read images as bytes and convert to base64 for sending
|
||||||
|
final sourceBytes = await File(sourceImage.path).readAsBytes();
|
||||||
|
final targetBytes = await File(targetImage.path).readAsBytes();
|
||||||
|
|
||||||
|
// Prepare request payload
|
||||||
|
final payload = {
|
||||||
|
'sourceImage': base64Encode(sourceBytes),
|
||||||
|
'targetImage': base64Encode(targetBytes),
|
||||||
|
'options': {
|
||||||
|
'threshold': 80.0, // Default similarity threshold (80%)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call the Supabase Edge Function
|
||||||
|
final res = await supabase.functions.invoke(
|
||||||
|
_verifyFaceFunction,
|
||||||
|
body: payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Process the response
|
||||||
|
final data = res.data;
|
||||||
|
|
||||||
|
return FaceComparisonResult.fromEdgeFunction(
|
||||||
|
sourceFace,
|
||||||
|
targetFace,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
lastException = e is Exception ? e : Exception(e.toString());
|
||||||
|
retries++;
|
||||||
|
|
||||||
|
// Wait before retrying
|
||||||
|
if (retries <= _maxRetries) {
|
||||||
|
await Future.delayed(Duration(seconds: retries * 2));
|
||||||
|
print('Retrying face comparison (attempt $retries)...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, all retries failed
|
||||||
|
print('Face comparison failed after $_maxRetries retries: $lastException');
|
||||||
|
return FaceComparisonResult.error(
|
||||||
|
FaceModel.empty().withMessage('Source face processing error'),
|
||||||
|
FaceModel.empty().withMessage('Target face processing error'),
|
||||||
|
'Face comparison failed after multiple attempts. Please try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,87 @@
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:sigap/src/cores/services/edge_function_service.dart';
|
||||||
|
import 'package:sigap/src/features/auth/data/models/face_model.dart';
|
||||||
|
|
||||||
|
/// Service that exclusively handles facial verification operations using edge functions
|
||||||
|
class FacialVerificationService {
|
||||||
|
// Singleton instance
|
||||||
|
static final FacialVerificationService instance =
|
||||||
|
FacialVerificationService._();
|
||||||
|
FacialVerificationService._();
|
||||||
|
|
||||||
|
// Service for face operations - only using edge functions
|
||||||
|
final EdgeFunctionService _edgeFunctionService = EdgeFunctionService.instance;
|
||||||
|
|
||||||
|
// Flag to bypass actual verification (for development/testing)
|
||||||
|
bool skipFaceVerification = true; // Set to true to skip verification
|
||||||
|
|
||||||
|
/// Detect faces in an image
|
||||||
|
Future<List<FaceModel>> detectFaces(XFile imageFile) async {
|
||||||
|
if (skipFaceVerification) {
|
||||||
|
// Return dummy successful result
|
||||||
|
return [
|
||||||
|
FaceModel(
|
||||||
|
imagePath: imageFile.path,
|
||||||
|
faceId: 'dummy-face-id-${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
confidence: 0.99,
|
||||||
|
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _edgeFunctionService.detectFaces(imageFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform liveness check
|
||||||
|
Future<FaceModel> performLivenessCheck(XFile selfieImage) async {
|
||||||
|
if (skipFaceVerification) {
|
||||||
|
// Return dummy successful liveness check
|
||||||
|
return FaceModel(
|
||||||
|
imagePath: selfieImage.path,
|
||||||
|
faceId: 'dummy-face-id-${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
confidence: 0.99,
|
||||||
|
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
|
||||||
|
).withLiveness(
|
||||||
|
isLive: true,
|
||||||
|
confidence: 0.95,
|
||||||
|
message: 'Liveness check passed (development mode)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _edgeFunctionService.performLivenessCheck(selfieImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare faces
|
||||||
|
Future<FaceComparisonResult> compareFaces(
|
||||||
|
XFile sourceImage,
|
||||||
|
XFile targetImage,
|
||||||
|
) async {
|
||||||
|
if (skipFaceVerification) {
|
||||||
|
// Create dummy source and target faces
|
||||||
|
final sourceFace = FaceModel(
|
||||||
|
imagePath: sourceImage.path,
|
||||||
|
faceId: 'dummy-source-id-${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
confidence: 0.98,
|
||||||
|
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
|
||||||
|
);
|
||||||
|
|
||||||
|
final targetFace = FaceModel(
|
||||||
|
imagePath: targetImage.path,
|
||||||
|
faceId: 'dummy-target-id-${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
confidence: 0.97,
|
||||||
|
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return dummy successful comparison
|
||||||
|
return FaceComparisonResult(
|
||||||
|
sourceFace: sourceFace,
|
||||||
|
targetFace: targetFace,
|
||||||
|
isMatch: true,
|
||||||
|
confidence: 0.92,
|
||||||
|
message: 'Faces match (development mode)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _edgeFunctionService.compareFaces(sourceImage, targetImage);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,228 @@
|
||||||
|
import 'package:sigap/src/features/auth/data/models/administrative_division.dart';
|
||||||
|
|
||||||
|
// Sample data for provinces
|
||||||
|
final List<AdministrativeDivision> provinces = [
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'p1',
|
||||||
|
name: 'Jawa Timur',
|
||||||
|
type: DivisionType.province,
|
||||||
|
code: '35',
|
||||||
|
),
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'p2',
|
||||||
|
name: 'Jawa Barat',
|
||||||
|
type: DivisionType.province,
|
||||||
|
code: '32',
|
||||||
|
),
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'p3',
|
||||||
|
name: 'Jawa Tengah',
|
||||||
|
type: DivisionType.province,
|
||||||
|
code: '33',
|
||||||
|
),
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'p4',
|
||||||
|
name: 'DKI Jakarta',
|
||||||
|
type: DivisionType.province,
|
||||||
|
code: '31',
|
||||||
|
),
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'p5',
|
||||||
|
name: 'Banten',
|
||||||
|
type: DivisionType.province,
|
||||||
|
code: '36',
|
||||||
|
),
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'p6',
|
||||||
|
name: 'Bali',
|
||||||
|
type: DivisionType.province,
|
||||||
|
code: '51',
|
||||||
|
),
|
||||||
|
// Add more provinces as needed
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sample data for regencies (cities/kabupaten)
|
||||||
|
final List<AdministrativeDivision> regencies = [
|
||||||
|
// Jawa Timur Regencies
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'r1',
|
||||||
|
name: 'Kota Surabaya',
|
||||||
|
type: DivisionType.regency,
|
||||||
|
parentId: 'p1',
|
||||||
|
code: '3578',
|
||||||
|
),
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'r2',
|
||||||
|
name: 'Kabupaten Sidoarjo',
|
||||||
|
type: DivisionType.regency,
|
||||||
|
parentId: 'p1',
|
||||||
|
code: '3515',
|
||||||
|
),
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'r3',
|
||||||
|
name: 'Kota Malang',
|
||||||
|
type: DivisionType.regency,
|
||||||
|
parentId: 'p1',
|
||||||
|
code: '3573',
|
||||||
|
),
|
||||||
|
|
||||||
|
// Jawa Barat Regencies
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'r4',
|
||||||
|
name: 'Kota Bandung',
|
||||||
|
type: DivisionType.regency,
|
||||||
|
parentId: 'p2',
|
||||||
|
code: '3273',
|
||||||
|
),
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'r5',
|
||||||
|
name: 'Kabupaten Bogor',
|
||||||
|
type: DivisionType.regency,
|
||||||
|
parentId: 'p2',
|
||||||
|
code: '3201',
|
||||||
|
),
|
||||||
|
|
||||||
|
// DKI Jakarta Regencies
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'r6',
|
||||||
|
name: 'Jakarta Selatan',
|
||||||
|
type: DivisionType.regency,
|
||||||
|
parentId: 'p4',
|
||||||
|
code: '3171',
|
||||||
|
),
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'r7',
|
||||||
|
name: 'Jakarta Pusat',
|
||||||
|
type: DivisionType.regency,
|
||||||
|
parentId: 'p4',
|
||||||
|
code: '3173',
|
||||||
|
),
|
||||||
|
// Add more regencies as needed
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sample data for districts (kecamatan)
|
||||||
|
final List<AdministrativeDivision> districts = [
|
||||||
|
// Surabaya Districts
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'd1',
|
||||||
|
name: 'Wonokromo',
|
||||||
|
type: DivisionType.district,
|
||||||
|
parentId: 'r1',
|
||||||
|
code: '357806',
|
||||||
|
),
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'd2',
|
||||||
|
name: 'Gubeng',
|
||||||
|
type: DivisionType.district,
|
||||||
|
parentId: 'r1',
|
||||||
|
code: '357807',
|
||||||
|
),
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'd3',
|
||||||
|
name: 'Rungkut',
|
||||||
|
type: DivisionType.district,
|
||||||
|
parentId: 'r1',
|
||||||
|
code: '357808',
|
||||||
|
),
|
||||||
|
|
||||||
|
// Sidoarjo Districts
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'd4',
|
||||||
|
name: 'Sidoarjo',
|
||||||
|
type: DivisionType.district,
|
||||||
|
parentId: 'r2',
|
||||||
|
code: '351501',
|
||||||
|
),
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'd5',
|
||||||
|
name: 'Candi',
|
||||||
|
type: DivisionType.district,
|
||||||
|
parentId: 'r2',
|
||||||
|
code: '351502',
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bandung Districts
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'd6',
|
||||||
|
name: 'Coblong',
|
||||||
|
type: DivisionType.district,
|
||||||
|
parentId: 'r4',
|
||||||
|
code: '327305',
|
||||||
|
),
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'd7',
|
||||||
|
name: 'Bandung Wetan',
|
||||||
|
type: DivisionType.district,
|
||||||
|
parentId: 'r4',
|
||||||
|
code: '327306',
|
||||||
|
),
|
||||||
|
// Add more districts as needed
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sample data for villages (kelurahan/desa)
|
||||||
|
final List<AdministrativeDivision> villages = [
|
||||||
|
// Wonokromo Villages
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'v1',
|
||||||
|
name: 'Darmo',
|
||||||
|
type: DivisionType.village,
|
||||||
|
parentId: 'd1',
|
||||||
|
code: '35780601',
|
||||||
|
),
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'v2',
|
||||||
|
name: 'Jagir',
|
||||||
|
type: DivisionType.village,
|
||||||
|
parentId: 'd1',
|
||||||
|
code: '35780602',
|
||||||
|
),
|
||||||
|
|
||||||
|
// Gubeng Villages
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'v3',
|
||||||
|
name: 'Airlangga',
|
||||||
|
type: DivisionType.village,
|
||||||
|
parentId: 'd2',
|
||||||
|
code: '35780701',
|
||||||
|
),
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'v4',
|
||||||
|
name: 'Gubeng',
|
||||||
|
type: DivisionType.village,
|
||||||
|
parentId: 'd2',
|
||||||
|
code: '35780702',
|
||||||
|
),
|
||||||
|
|
||||||
|
// Sidoarjo Villages
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'v5',
|
||||||
|
name: 'Sidoklumpuk',
|
||||||
|
type: DivisionType.village,
|
||||||
|
parentId: 'd4',
|
||||||
|
code: '35150101',
|
||||||
|
),
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'v6',
|
||||||
|
name: 'Sidokare',
|
||||||
|
type: DivisionType.village,
|
||||||
|
parentId: 'd4',
|
||||||
|
code: '35150102',
|
||||||
|
),
|
||||||
|
|
||||||
|
// Coblong Villages
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'v7',
|
||||||
|
name: 'Dago',
|
||||||
|
type: DivisionType.village,
|
||||||
|
parentId: 'd6',
|
||||||
|
code: '32730501',
|
||||||
|
),
|
||||||
|
AdministrativeDivision(
|
||||||
|
id: 'v8',
|
||||||
|
name: 'Lebak Gede',
|
||||||
|
type: DivisionType.village,
|
||||||
|
parentId: 'd6',
|
||||||
|
code: '32730502',
|
||||||
|
),
|
||||||
|
// Add more villages as needed
|
||||||
|
];
|
|
@ -1,6 +1,127 @@
|
||||||
// List of Indonesian cities and regencies
|
// This file contains a list of Indonesian cities and their organization by province
|
||||||
// This is a partial list - in a real app, you would have a complete list
|
|
||||||
final List<String> indonesianCities = [
|
// Complete list of cities/regencies in Indonesia (simplified for example)
|
||||||
|
List<String> indonesianCities = [
|
||||||
|
// Java
|
||||||
|
'Jakarta', 'Surabaya', 'Bandung', 'Bekasi', 'Tangerang',
|
||||||
|
'Depok', 'Semarang', 'Medan', 'Makassar', 'Palembang',
|
||||||
|
'Yogyakarta', 'Surakarta (Solo)', 'Malang', 'Bogor', 'Denpasar',
|
||||||
|
'Samarinda', 'Padang', 'Bandar Lampung', 'Jambi', 'Manado',
|
||||||
|
'Pekanbaru', 'Pontianak', 'Banjarmasin', 'Balikpapan', 'Serang',
|
||||||
|
'Cirebon', 'Kupang', 'Jayapura', 'Ambon', 'Mataram',
|
||||||
|
'Banda Aceh', 'Bengkulu', 'Ternate', 'Kendari', 'Palu',
|
||||||
|
'Gorontalo', 'Tanjung Pinang', 'Pangkal Pinang', 'Mamuju', 'Sorong',
|
||||||
|
'Bima', 'Tual', 'Manokwari', 'Bau-Bau', 'Pematang Siantar',
|
||||||
|
'Tanjung Balai', 'Binjai', 'Bukittinggi', 'Pariaman', 'Padang Panjang',
|
||||||
|
'Dumai', 'Payakumbuh', 'Banda Aceh', 'Sabang', 'Langsa',
|
||||||
|
'Lhokseumawe', 'Subulussalam', 'Sibolga', 'Padang Sidempuan', 'Gunungsitoli',
|
||||||
|
'Tanjungbalai', 'Tebing Tinggi', 'Jambi', 'Sungai Penuh', 'Palembang',
|
||||||
|
'Prabumulih', 'Lubuklinggau', 'Pagar Alam', 'Bengkulu', 'Metro',
|
||||||
|
'Cilegon', 'Tangerang Selatan', 'Magelang', 'Pekalongan', 'Salatiga',
|
||||||
|
'Tegal', 'Blitar', 'Kediri', 'Madiun', 'Mojokerto',
|
||||||
|
'Pasuruan', 'Probolinggo', 'Batu', 'Pontianak', 'Singkawang',
|
||||||
|
'Banjarmasin', 'Banjarbaru', 'Palangka Raya', 'Tarakan', 'Bontang',
|
||||||
|
// Add more cities as needed
|
||||||
|
];
|
||||||
|
|
||||||
|
// Map of Indonesian provinces with their major cities
|
||||||
|
Map<String, List<String>> citiesByProvince = {
|
||||||
|
'Aceh': ['Banda Aceh', 'Sabang', 'Langsa', 'Lhokseumawe', 'Subulussalam'],
|
||||||
|
'Sumatera Utara': [
|
||||||
|
'Medan',
|
||||||
|
'Binjai',
|
||||||
|
'Padang Sidempuan',
|
||||||
|
'Pematangsiantar',
|
||||||
|
'Sibolga',
|
||||||
|
'Tanjungbalai',
|
||||||
|
'Tebing Tinggi',
|
||||||
|
],
|
||||||
|
'Sumatera Barat': [
|
||||||
|
'Padang',
|
||||||
|
'Bukittinggi',
|
||||||
|
'Pariaman',
|
||||||
|
'Padang Panjang',
|
||||||
|
'Payakumbuh',
|
||||||
|
'Solok',
|
||||||
|
'Sawahlunto',
|
||||||
|
],
|
||||||
|
'Riau': ['Pekanbaru', 'Dumai'],
|
||||||
|
'Kepulauan Riau': ['Tanjung Pinang', 'Batam'],
|
||||||
|
'Jambi': ['Jambi', 'Sungai Penuh'],
|
||||||
|
'Sumatera Selatan': ['Palembang', 'Prabumulih', 'Lubuklinggau', 'Pagar Alam'],
|
||||||
|
'Bangka Belitung': ['Pangkal Pinang'],
|
||||||
|
'Bengkulu': ['Bengkulu'],
|
||||||
|
'Lampung': ['Bandar Lampung', 'Metro'],
|
||||||
|
'DKI Jakarta': [
|
||||||
|
'Jakarta Pusat',
|
||||||
|
'Jakarta Utara',
|
||||||
|
'Jakarta Barat',
|
||||||
|
'Jakarta Selatan',
|
||||||
|
'Jakarta Timur',
|
||||||
|
],
|
||||||
|
'Banten': ['Serang', 'Cilegon', 'Tangerang', 'Tangerang Selatan'],
|
||||||
|
'Jawa Barat': [
|
||||||
|
'Bandung',
|
||||||
|
'Bekasi',
|
||||||
|
'Bogor',
|
||||||
|
'Cimahi',
|
||||||
|
'Cirebon',
|
||||||
|
'Depok',
|
||||||
|
'Sukabumi',
|
||||||
|
'Tasikmalaya',
|
||||||
|
'Banjar',
|
||||||
|
],
|
||||||
|
'Jawa Tengah': [
|
||||||
|
'Semarang',
|
||||||
|
'Solo',
|
||||||
|
'Pekalongan',
|
||||||
|
'Magelang',
|
||||||
|
'Salatiga',
|
||||||
|
'Surakarta',
|
||||||
|
'Tegal',
|
||||||
|
],
|
||||||
|
'Yogyakarta': ['Yogyakarta'],
|
||||||
|
'Jawa Timur': [
|
||||||
|
'Surabaya',
|
||||||
|
'Malang',
|
||||||
|
'Kediri',
|
||||||
|
'Batu',
|
||||||
|
'Blitar',
|
||||||
|
'Madiun',
|
||||||
|
'Mojokerto',
|
||||||
|
'Pasuruan',
|
||||||
|
'Probolinggo',
|
||||||
|
],
|
||||||
|
'Bali': [
|
||||||
|
'Denpasar',
|
||||||
|
'Badung',
|
||||||
|
'Gianyar',
|
||||||
|
'Tabanan',
|
||||||
|
'Klungkung',
|
||||||
|
'Bangli',
|
||||||
|
'Buleleng',
|
||||||
|
],
|
||||||
|
'Nusa Tenggara Barat': ['Mataram', 'Bima'],
|
||||||
|
'Nusa Tenggara Timur': ['Kupang'],
|
||||||
|
'Kalimantan Barat': ['Pontianak', 'Singkawang'],
|
||||||
|
'Kalimantan Tengah': ['Palangka Raya'],
|
||||||
|
'Kalimantan Selatan': ['Banjarmasin', 'Banjarbaru'],
|
||||||
|
'Kalimantan Timur': ['Samarinda', 'Balikpapan', 'Bontang'],
|
||||||
|
'Kalimantan Utara': ['Tarakan'],
|
||||||
|
'Sulawesi Utara': ['Manado', 'Bitung', 'Kotamobagu', 'Tomohon'],
|
||||||
|
'Gorontalo': ['Gorontalo'],
|
||||||
|
'Sulawesi Tengah': ['Palu'],
|
||||||
|
'Sulawesi Barat': ['Mamuju'],
|
||||||
|
'Sulawesi Selatan': ['Makassar', 'Palopo', 'Parepare'],
|
||||||
|
'Sulawesi Tenggara': ['Kendari', 'Bau-Bau'],
|
||||||
|
'Maluku': ['Ambon', 'Tual'],
|
||||||
|
'Maluku Utara': ['Ternate', 'Tidore'],
|
||||||
|
'Papua Barat': ['Manokwari', 'Sorong'],
|
||||||
|
'Papua': ['Jayapura'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Popular cities in Indonesia
|
||||||
|
List<String> popularCities = [
|
||||||
'Jakarta',
|
'Jakarta',
|
||||||
'Surabaya',
|
'Surabaya',
|
||||||
'Bandung',
|
'Bandung',
|
||||||
|
@ -8,523 +129,67 @@ final List<String> indonesianCities = [
|
||||||
'Semarang',
|
'Semarang',
|
||||||
'Makassar',
|
'Makassar',
|
||||||
'Palembang',
|
'Palembang',
|
||||||
'Tangerang',
|
|
||||||
'Depok',
|
|
||||||
'Bekasi',
|
|
||||||
'Bogor',
|
|
||||||
'Malang',
|
|
||||||
'Yogyakarta',
|
'Yogyakarta',
|
||||||
'Denpasar',
|
'Denpasar',
|
||||||
'Balikpapan',
|
'Balikpapan',
|
||||||
'Banjarmasin',
|
];
|
||||||
'Manado',
|
|
||||||
'Padang',
|
// Group cities by province
|
||||||
'Pekanbaru',
|
Map<String, List<String>> provinceCities = {
|
||||||
'Pontianak',
|
'Jawa Barat': [
|
||||||
'Bandar Lampung',
|
'Bandung',
|
||||||
|
'Bekasi',
|
||||||
|
'Bogor',
|
||||||
|
'Cimahi',
|
||||||
'Cirebon',
|
'Cirebon',
|
||||||
'Tasikmalaya',
|
'Depok',
|
||||||
'Serang',
|
|
||||||
'Jambi',
|
|
||||||
'Bengkulu',
|
|
||||||
'Ambon',
|
|
||||||
'Kupang',
|
|
||||||
'Mataram',
|
|
||||||
'Palu',
|
|
||||||
'Samarinda',
|
|
||||||
'Kendari',
|
|
||||||
'Jayapura',
|
|
||||||
'Sorong',
|
|
||||||
'Gorontalo',
|
|
||||||
'Ternate',
|
|
||||||
'Tanjung Pinang',
|
|
||||||
'Pangkal Pinang',
|
|
||||||
'Mamuju',
|
|
||||||
'Banda Aceh',
|
|
||||||
'Tegal',
|
|
||||||
'Pekalongan',
|
|
||||||
'Magelang',
|
|
||||||
'Sukabumi',
|
'Sukabumi',
|
||||||
'Cilegon',
|
'Tasikmalaya',
|
||||||
'Kediri',
|
|
||||||
'Pematang Siantar',
|
|
||||||
'Binjai',
|
|
||||||
'Tanjung Balai',
|
|
||||||
'Bitung',
|
|
||||||
'Pare-Pare',
|
|
||||||
'Tarakan',
|
|
||||||
'Bontang',
|
|
||||||
'Bau-Bau',
|
|
||||||
'Palopo',
|
|
||||||
'Sawahlunto',
|
|
||||||
'Padang Panjang',
|
|
||||||
'Bukittinggi',
|
|
||||||
'Payakumbuh',
|
|
||||||
'Pariaman',
|
|
||||||
'Solok',
|
|
||||||
'Lubuklinggau',
|
|
||||||
'Prabumulih',
|
|
||||||
'Palangkaraya',
|
|
||||||
'Singkawang',
|
|
||||||
'Bima',
|
|
||||||
'Probolinggo',
|
|
||||||
'Pasuruan',
|
|
||||||
'Mojokerto',
|
|
||||||
'Madiun',
|
|
||||||
'Blitar',
|
|
||||||
'Batu',
|
|
||||||
'Cianjur',
|
|
||||||
'Garut',
|
|
||||||
'Purwakarta',
|
|
||||||
'Ciamis',
|
|
||||||
'Karawang',
|
|
||||||
'Subang',
|
|
||||||
'Indramayu',
|
|
||||||
'Majalengka',
|
|
||||||
'Kuningan',
|
|
||||||
'Banjar',
|
'Banjar',
|
||||||
'Banjarbaru',
|
],
|
||||||
'Kotabaru',
|
'Jawa Tengah': [
|
||||||
'Sampit',
|
|
||||||
'Pangkalan Bun',
|
|
||||||
'Tanjung Selor',
|
|
||||||
'Tanjung Redeb',
|
|
||||||
'Nunukan',
|
|
||||||
'Malinau',
|
|
||||||
'Tanjung Pandan',
|
|
||||||
'Manggar',
|
|
||||||
'Sungai Liat',
|
|
||||||
'Tanjung Pati',
|
|
||||||
'Sungai Penuh',
|
|
||||||
'Muara Bungo',
|
|
||||||
'Muara Bulian',
|
|
||||||
'Muara Tebo',
|
|
||||||
'Bangko',
|
|
||||||
'Kuala Tungkal',
|
|
||||||
'Muara Sabak',
|
|
||||||
'Muara Enim',
|
|
||||||
'Lahat',
|
|
||||||
'Baturaja',
|
|
||||||
'Martapura',
|
|
||||||
'Kayu Agung',
|
|
||||||
'Sekayu',
|
|
||||||
'Pangkalpinang',
|
|
||||||
'Metro',
|
|
||||||
'Pringsewu',
|
|
||||||
'Kota Agung',
|
|
||||||
'Liwa',
|
|
||||||
'Menggala',
|
|
||||||
'Kotabumi',
|
|
||||||
'Krui',
|
|
||||||
'Sukadana',
|
|
||||||
'Curup',
|
|
||||||
'Manna',
|
|
||||||
'Argamakmur',
|
|
||||||
'Mukomuko',
|
|
||||||
'Tais',
|
|
||||||
'Bintuhan',
|
|
||||||
'Kaur',
|
|
||||||
'Kepahiang',
|
|
||||||
'Lebong',
|
|
||||||
'Muara Aman',
|
|
||||||
'Seluma',
|
|
||||||
'Tanjung Karang',
|
|
||||||
'Teluk Betung',
|
|
||||||
'Kalianda',
|
|
||||||
'Gunung Sugih',
|
|
||||||
'Blambangan Umpu',
|
|
||||||
'Kotabumi',
|
|
||||||
'Gedong Tataan',
|
|
||||||
'Menggala',
|
|
||||||
'Kota Agung',
|
|
||||||
'Sukadana',
|
|
||||||
'Panarukan',
|
|
||||||
'Situbondo',
|
|
||||||
'Bondowoso',
|
|
||||||
'Jember',
|
|
||||||
'Banyuwangi',
|
|
||||||
'Lumajang',
|
|
||||||
'Kraksaan',
|
|
||||||
'Bangkalan',
|
|
||||||
'Sampang',
|
|
||||||
'Pamekasan',
|
|
||||||
'Sumenep',
|
|
||||||
'Ngawi',
|
|
||||||
'Ponorogo',
|
|
||||||
'Pacitan',
|
|
||||||
'Magetan',
|
|
||||||
'Nganjuk',
|
|
||||||
'Jombang',
|
|
||||||
'Tuban',
|
|
||||||
'Bojonegoro',
|
|
||||||
'Lamongan',
|
|
||||||
'Gresik',
|
|
||||||
'Sidoarjo',
|
|
||||||
'Mojokerto',
|
|
||||||
'Pasuruan',
|
|
||||||
'Probolinggo',
|
|
||||||
'Lumajang',
|
|
||||||
'Jember',
|
|
||||||
'Banyuwangi',
|
|
||||||
'Situbondo',
|
|
||||||
'Bondowoso',
|
|
||||||
'Trenggalek',
|
|
||||||
'Tulungagung',
|
|
||||||
'Blitar',
|
|
||||||
'Kediri',
|
|
||||||
'Malang',
|
|
||||||
'Purworejo',
|
|
||||||
'Kebumen',
|
|
||||||
'Magelang',
|
|
||||||
'Wonosobo',
|
|
||||||
'Temanggung',
|
|
||||||
'Kendal',
|
|
||||||
'Batang',
|
|
||||||
'Pekalongan',
|
|
||||||
'Pemalang',
|
|
||||||
'Tegal',
|
|
||||||
'Brebes',
|
|
||||||
'Banyumas',
|
|
||||||
'Cilacap',
|
|
||||||
'Purbalingga',
|
|
||||||
'Banjarnegara',
|
|
||||||
'Sragen',
|
|
||||||
'Karanganyar',
|
|
||||||
'Wonogiri',
|
|
||||||
'Sukoharjo',
|
|
||||||
'Klaten',
|
|
||||||
'Boyolali',
|
|
||||||
'Grobogan',
|
|
||||||
'Blora',
|
|
||||||
'Rembang',
|
|
||||||
'Pati',
|
|
||||||
'Kudus',
|
|
||||||
'Jepara',
|
|
||||||
'Demak',
|
|
||||||
'Semarang',
|
'Semarang',
|
||||||
|
'Solo',
|
||||||
|
'Pekalongan',
|
||||||
|
'Magelang',
|
||||||
'Salatiga',
|
'Salatiga',
|
||||||
'Surakarta',
|
'Surakarta',
|
||||||
'Bantul',
|
'Tegal',
|
||||||
'Sleman',
|
],
|
||||||
'Kulon Progo',
|
'Jawa Timur': [
|
||||||
'Gunung Kidul',
|
'Surabaya',
|
||||||
|
'Malang',
|
||||||
|
'Kediri',
|
||||||
|
'Batu',
|
||||||
|
'Blitar',
|
||||||
|
'Madiun',
|
||||||
|
'Mojokerto',
|
||||||
|
'Pasuruan',
|
||||||
|
'Probolinggo',
|
||||||
|
],
|
||||||
|
'Sumatera Utara': [
|
||||||
|
'Medan',
|
||||||
|
'Binjai',
|
||||||
|
'Padang Sidempuan',
|
||||||
|
'Pematangsiantar',
|
||||||
|
'Sibolga',
|
||||||
|
'Tanjungbalai',
|
||||||
|
'Tebing Tinggi',
|
||||||
|
],
|
||||||
|
'DKI Jakarta': [
|
||||||
|
'Jakarta Pusat',
|
||||||
|
'Jakarta Utara',
|
||||||
|
'Jakarta Barat',
|
||||||
|
'Jakarta Selatan',
|
||||||
|
'Jakarta Timur',
|
||||||
|
],
|
||||||
|
'Bali': [
|
||||||
|
'Denpasar',
|
||||||
'Badung',
|
'Badung',
|
||||||
|
'Gianyar',
|
||||||
|
'Tabanan',
|
||||||
|
'Klungkung',
|
||||||
'Bangli',
|
'Bangli',
|
||||||
'Buleleng',
|
'Buleleng',
|
||||||
'Gianyar',
|
],
|
||||||
'Jembrana',
|
};
|
||||||
'Karangasem',
|
|
||||||
'Klungkung',
|
|
||||||
'Tabanan',
|
|
||||||
'Mataram',
|
|
||||||
'Bima',
|
|
||||||
'Dompu',
|
|
||||||
'Sumbawa',
|
|
||||||
'Lombok Barat',
|
|
||||||
'Lombok Tengah',
|
|
||||||
'Lombok Timur',
|
|
||||||
'Lombok Utara',
|
|
||||||
'Kupang',
|
|
||||||
'Ende',
|
|
||||||
'Maumere',
|
|
||||||
'Labuan Bajo',
|
|
||||||
'Ruteng',
|
|
||||||
'Waingapu',
|
|
||||||
'Waikabubak',
|
|
||||||
'Atambua',
|
|
||||||
'Kefamenanu',
|
|
||||||
'Soe',
|
|
||||||
'Bajawa',
|
|
||||||
'Larantuka',
|
|
||||||
'Lewoleba',
|
|
||||||
'Kalabahi',
|
|
||||||
'Sabu',
|
|
||||||
'Rote',
|
|
||||||
'Alor',
|
|
||||||
'Lembata',
|
|
||||||
'Flores Timur',
|
|
||||||
'Sikka',
|
|
||||||
'Ende',
|
|
||||||
'Ngada',
|
|
||||||
'Manggarai',
|
|
||||||
'Manggarai Barat',
|
|
||||||
'Manggarai Timur',
|
|
||||||
'Sumba Barat',
|
|
||||||
'Sumba Timur',
|
|
||||||
'Sumba Tengah',
|
|
||||||
'Sumba Barat Daya',
|
|
||||||
'Belu',
|
|
||||||
'Malaka',
|
|
||||||
'Timor Tengah Utara',
|
|
||||||
'Timor Tengah Selatan',
|
|
||||||
'Rote Ndao',
|
|
||||||
'Sabu Raijua',
|
|
||||||
'Pontianak',
|
|
||||||
'Singkawang',
|
|
||||||
'Sambas',
|
|
||||||
'Bengkayang',
|
|
||||||
'Landak',
|
|
||||||
'Mempawah',
|
|
||||||
'Sanggau',
|
|
||||||
'Ketapang',
|
|
||||||
'Sintang',
|
|
||||||
'Kapuas Hulu',
|
|
||||||
'Sekadau',
|
|
||||||
'Melawi',
|
|
||||||
'Kayong Utara',
|
|
||||||
'Kubu Raya',
|
|
||||||
'Palangkaraya',
|
|
||||||
'Sampit',
|
|
||||||
'Pangkalan Bun',
|
|
||||||
'Kuala Kapuas',
|
|
||||||
'Buntok',
|
|
||||||
'Muara Teweh',
|
|
||||||
'Puruk Cahu',
|
|
||||||
'Kuala Kurun',
|
|
||||||
'Kuala Pembuang',
|
|
||||||
'Kasongan',
|
|
||||||
'Tamiang Layang',
|
|
||||||
'Nanga Bulik',
|
|
||||||
'Sukamara',
|
|
||||||
'Pulang Pisau',
|
|
||||||
'Lamandau',
|
|
||||||
'Seruyan',
|
|
||||||
'Katingan',
|
|
||||||
'Gunung Mas',
|
|
||||||
'Barito Timur',
|
|
||||||
'Barito Utara',
|
|
||||||
'Barito Selatan',
|
|
||||||
'Murung Raya',
|
|
||||||
'Banjarmasin',
|
|
||||||
'Banjarbaru',
|
|
||||||
'Martapura',
|
|
||||||
'Pelaihari',
|
|
||||||
'Kotabaru',
|
|
||||||
'Tanjung',
|
|
||||||
'Barabai',
|
|
||||||
'Amuntai',
|
|
||||||
'Kandangan',
|
|
||||||
'Rantau',
|
|
||||||
'Marabahan',
|
|
||||||
'Paringin',
|
|
||||||
'Balangan',
|
|
||||||
'Tanah Bumbu',
|
|
||||||
'Tanah Laut',
|
|
||||||
'Tapin',
|
|
||||||
'Hulu Sungai Selatan',
|
|
||||||
'Hulu Sungai Tengah',
|
|
||||||
'Hulu Sungai Utara',
|
|
||||||
'Tabalong',
|
|
||||||
'Barito Kuala',
|
|
||||||
'Samarinda',
|
|
||||||
'Balikpapan',
|
|
||||||
'Bontang',
|
|
||||||
'Tenggarong',
|
|
||||||
'Sangatta',
|
|
||||||
'Sendawar',
|
|
||||||
'Tanah Grogot',
|
|
||||||
'Penajam',
|
|
||||||
'Tanjung Redeb',
|
|
||||||
'Tanjung Selor',
|
|
||||||
'Malinau',
|
|
||||||
'Tarakan',
|
|
||||||
'Nunukan',
|
|
||||||
'Kutai Kartanegara',
|
|
||||||
'Kutai Timur',
|
|
||||||
'Kutai Barat',
|
|
||||||
'Paser',
|
|
||||||
'Penajam Paser Utara',
|
|
||||||
'Berau',
|
|
||||||
'Bulungan',
|
|
||||||
'Malinau',
|
|
||||||
'Nunukan',
|
|
||||||
'Tana Tidung',
|
|
||||||
'Makassar',
|
|
||||||
'Pare-Pare',
|
|
||||||
'Palopo',
|
|
||||||
'Maros',
|
|
||||||
'Pangkajene',
|
|
||||||
'Barru',
|
|
||||||
'Watampone',
|
|
||||||
'Watansoppeng',
|
|
||||||
'Sengkang',
|
|
||||||
'Makale',
|
|
||||||
'Rantepao',
|
|
||||||
'Enrekang',
|
|
||||||
'Pinrang',
|
|
||||||
'Sidenreng Rappang',
|
|
||||||
'Sungguminasa',
|
|
||||||
'Takalar',
|
|
||||||
'Jeneponto',
|
|
||||||
'Bantaeng',
|
|
||||||
'Bulukumba',
|
|
||||||
'Sinjai',
|
|
||||||
'Benteng',
|
|
||||||
'Bau-Bau',
|
|
||||||
'Kendari',
|
|
||||||
'Kolaka',
|
|
||||||
'Raha',
|
|
||||||
'Unaaha',
|
|
||||||
'Andoolo',
|
|
||||||
'Rumbia',
|
|
||||||
'Wangi-Wangi',
|
|
||||||
'Baubau',
|
|
||||||
'Bombana',
|
|
||||||
'Buton',
|
|
||||||
'Buton Utara',
|
|
||||||
'Kolaka',
|
|
||||||
'Kolaka Timur',
|
|
||||||
'Kolaka Utara',
|
|
||||||
'Konawe',
|
|
||||||
'Konawe Selatan',
|
|
||||||
'Konawe Utara',
|
|
||||||
'Muna',
|
|
||||||
'Muna Barat',
|
|
||||||
'Wakatobi',
|
|
||||||
'Palu',
|
|
||||||
'Luwuk',
|
|
||||||
'Toli-Toli',
|
|
||||||
'Buol',
|
|
||||||
'Donggala',
|
|
||||||
'Poso',
|
|
||||||
'Ampana',
|
|
||||||
'Bungku',
|
|
||||||
'Kolonodale',
|
|
||||||
'Banggai',
|
|
||||||
'Banggai Kepulauan',
|
|
||||||
'Banggai Laut',
|
|
||||||
'Buol',
|
|
||||||
'Donggala',
|
|
||||||
'Morowali',
|
|
||||||
'Morowali Utara',
|
|
||||||
'Parigi Moutong',
|
|
||||||
'Poso',
|
|
||||||
'Sigi',
|
|
||||||
'Tojo Una-Una',
|
|
||||||
'Toli-Toli',
|
|
||||||
'Manado',
|
|
||||||
'Bitung',
|
|
||||||
'Tomohon',
|
|
||||||
'Kotamobagu',
|
|
||||||
'Tahuna',
|
|
||||||
'Melonguane',
|
|
||||||
'Airmadidi',
|
|
||||||
'Ratahan',
|
|
||||||
'Amurang',
|
|
||||||
'Tondano',
|
|
||||||
'Bolaang Mongondow',
|
|
||||||
'Bolaang Mongondow Selatan',
|
|
||||||
'Bolaang Mongondow Timur',
|
|
||||||
'Bolaang Mongondow Utara',
|
|
||||||
'Kepulauan Sangihe',
|
|
||||||
'Kepulauan Siau Tagulandang Biaro',
|
|
||||||
'Kepulauan Talaud',
|
|
||||||
'Minahasa',
|
|
||||||
'Minahasa Selatan',
|
|
||||||
'Minahasa Tenggara',
|
|
||||||
'Minahasa Utara',
|
|
||||||
'Gorontalo',
|
|
||||||
'Limboto',
|
|
||||||
'Marisa',
|
|
||||||
'Tilamuta',
|
|
||||||
'Suwawa',
|
|
||||||
'Kwandang',
|
|
||||||
'Boalemo',
|
|
||||||
'Bone Bolango',
|
|
||||||
'Gorontalo',
|
|
||||||
'Gorontalo Utara',
|
|
||||||
'Pohuwato',
|
|
||||||
'Ternate',
|
|
||||||
'Tidore',
|
|
||||||
'Jailolo',
|
|
||||||
'Weda',
|
|
||||||
'Labuha',
|
|
||||||
'Tobelo',
|
|
||||||
'Maba',
|
|
||||||
'Sanana',
|
|
||||||
'Daruba',
|
|
||||||
'Halmahera Barat',
|
|
||||||
'Halmahera Tengah',
|
|
||||||
'Halmahera Utara',
|
|
||||||
'Halmahera Selatan',
|
|
||||||
'Halmahera Timur',
|
|
||||||
'Kepulauan Sula',
|
|
||||||
'Pulau Morotai',
|
|
||||||
'Pulau Taliabu',
|
|
||||||
'Ambon',
|
|
||||||
'Tual',
|
|
||||||
'Masohi',
|
|
||||||
'Piru',
|
|
||||||
'Dobo',
|
|
||||||
'Saumlaki',
|
|
||||||
'Namlea',
|
|
||||||
'Dataran Hunimoa',
|
|
||||||
'Tiakur',
|
|
||||||
'Buru',
|
|
||||||
'Buru Selatan',
|
|
||||||
'Kepulauan Aru',
|
|
||||||
'Maluku Barat Daya',
|
|
||||||
'Maluku Tengah',
|
|
||||||
'Maluku Tenggara',
|
|
||||||
'Maluku Tenggara Barat',
|
|
||||||
'Seram Bagian Barat',
|
|
||||||
'Seram Bagian Timur',
|
|
||||||
'Jayapura',
|
|
||||||
'Merauke',
|
|
||||||
'Biak',
|
|
||||||
'Nabire',
|
|
||||||
'Wamena',
|
|
||||||
'Timika',
|
|
||||||
'Sarmi',
|
|
||||||
'Serui',
|
|
||||||
'Sentani',
|
|
||||||
'Agats',
|
|
||||||
'Asmat',
|
|
||||||
'Biak Numfor',
|
|
||||||
'Boven Digoel',
|
|
||||||
'Deiyai',
|
|
||||||
'Dogiyai',
|
|
||||||
'Intan Jaya',
|
|
||||||
'Jayapura',
|
|
||||||
'Jayawijaya',
|
|
||||||
'Keerom',
|
|
||||||
'Kepulauan Yapen',
|
|
||||||
'Lanny Jaya',
|
|
||||||
'Mamberamo Raya',
|
|
||||||
'Mamberamo Tengah',
|
|
||||||
'Mappi',
|
|
||||||
'Merauke',
|
|
||||||
'Mimika',
|
|
||||||
'Nabire',
|
|
||||||
'Nduga',
|
|
||||||
'Paniai',
|
|
||||||
'Pegunungan Bintang',
|
|
||||||
'Puncak',
|
|
||||||
'Puncak Jaya',
|
|
||||||
'Sarmi',
|
|
||||||
'Supiori',
|
|
||||||
'Tolikara',
|
|
||||||
'Waropen',
|
|
||||||
'Yahukimo',
|
|
||||||
'Yalimo',
|
|
||||||
'Sorong',
|
|
||||||
'Manokwari',
|
|
||||||
'Fak-Fak',
|
|
||||||
'Kaimana',
|
|
||||||
'Teminabuan',
|
|
||||||
'Waisai',
|
|
||||||
'Rasiei',
|
|
||||||
'Bintuni',
|
|
||||||
'Sorong',
|
|
||||||
'Sorong Selatan',
|
|
||||||
'Raja Ampat',
|
|
||||||
'Tambrauw',
|
|
||||||
'Maybrat',
|
|
||||||
'Manokwari',
|
|
||||||
'Manokwari Selatan',
|
|
||||||
'Pegunungan Arfak',
|
|
||||||
'Teluk Bintuni',
|
|
||||||
'Teluk Wondama',
|
|
||||||
'Fakfak',
|
|
||||||
'Kaimana',
|
|
||||||
];
|
|
||||||
|
|
|
@ -0,0 +1,94 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Types of administrative divisions in Indonesia
|
||||||
|
enum DivisionType {
|
||||||
|
province,
|
||||||
|
regency,
|
||||||
|
district,
|
||||||
|
village
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Model for representing administrative divisions in Indonesia
|
||||||
|
class AdministrativeDivision {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final DivisionType type;
|
||||||
|
final String? parentId;
|
||||||
|
final String? code;
|
||||||
|
final Map<String, dynamic>? populationData;
|
||||||
|
|
||||||
|
AdministrativeDivision({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.type,
|
||||||
|
this.parentId,
|
||||||
|
this.code,
|
||||||
|
this.populationData,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Creates a copy with the given fields replaced with the new values
|
||||||
|
AdministrativeDivision copyWith({
|
||||||
|
String? id,
|
||||||
|
String? name,
|
||||||
|
DivisionType? type,
|
||||||
|
String? parentId,
|
||||||
|
String? code,
|
||||||
|
Map<String, dynamic>? populationData,
|
||||||
|
}) {
|
||||||
|
return AdministrativeDivision(
|
||||||
|
id: id ?? this.id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
type: type ?? this.type,
|
||||||
|
parentId: parentId ?? this.parentId,
|
||||||
|
code: code ?? this.code,
|
||||||
|
populationData: populationData ?? this.populationData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Factory method to create from provinsi.json structure
|
||||||
|
factory AdministrativeDivision.fromProvinceJson(Map<String, dynamic> json) {
|
||||||
|
return AdministrativeDivision(
|
||||||
|
id: json['kode_wilayah'],
|
||||||
|
name: json['nama_wilayah'],
|
||||||
|
type: DivisionType.province,
|
||||||
|
code: json['kode_wilayah'],
|
||||||
|
populationData: json['populasi'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Factory method to create from kabupaten_kota.json structure
|
||||||
|
factory AdministrativeDivision.fromRegencyJson(Map<String, dynamic> json) {
|
||||||
|
return AdministrativeDivision(
|
||||||
|
id: json['kode_wilayah'],
|
||||||
|
name: json['nama_wilayah'],
|
||||||
|
type: DivisionType.regency,
|
||||||
|
parentId: json['kode_provinsi'],
|
||||||
|
code: json['kode_wilayah'],
|
||||||
|
populationData: json['populasi'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Factory method to create from kecamatan.json structure
|
||||||
|
factory AdministrativeDivision.fromDistrictJson(Map<String, dynamic> json) {
|
||||||
|
return AdministrativeDivision(
|
||||||
|
id: json['kode_wilayah'],
|
||||||
|
name: json['nama_wilayah'],
|
||||||
|
type: DivisionType.district,
|
||||||
|
parentId: json['kode_kabupaten'],
|
||||||
|
code: json['kode_wilayah'],
|
||||||
|
populationData: json['populasi'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Factory method to create from desa_kelurahan.json structure
|
||||||
|
factory AdministrativeDivision.fromVillageJson(Map<String, dynamic> json) {
|
||||||
|
return AdministrativeDivision(
|
||||||
|
id: json['kode_wilayah'],
|
||||||
|
name: json['nama_wilayah'],
|
||||||
|
type: DivisionType.village,
|
||||||
|
parentId: json['kode_kecamatan'],
|
||||||
|
code: json['kode_wilayah'],
|
||||||
|
populationData: json['populasi'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,61 +2,129 @@ import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
/// A model class that represents facial recognition data and metrics
|
/// A model class that represents facial recognition data and metrics
|
||||||
class FaceModel {
|
class FaceModel {
|
||||||
|
/// Path to the source image that contains the face
|
||||||
|
final String imagePath;
|
||||||
|
|
||||||
/// Unique identifier for the face
|
/// Unique identifier for the face
|
||||||
final String faceId;
|
final String faceId;
|
||||||
|
|
||||||
/// Source image that contains the face
|
|
||||||
final XFile? sourceImage;
|
|
||||||
|
|
||||||
/// Raw face details as returned from AWS Rekognition
|
|
||||||
final Map<String, dynamic> faceDetails;
|
|
||||||
|
|
||||||
/// Confidence score for face detection (0.0-1.0)
|
/// Confidence score for face detection (0.0-1.0)
|
||||||
final double detectionConfidence;
|
final double confidence;
|
||||||
|
|
||||||
/// Whether the face passed liveness detection
|
/// Bounding box of face in the image
|
||||||
|
final Map<String, double> boundingBox;
|
||||||
|
|
||||||
|
/// Age range information
|
||||||
|
final int? minAge;
|
||||||
|
final int? maxAge;
|
||||||
|
|
||||||
|
/// Gender information
|
||||||
|
final String? gender;
|
||||||
|
final double? genderConfidence;
|
||||||
|
|
||||||
|
/// Liveness detection data
|
||||||
final bool isLive;
|
final bool isLive;
|
||||||
|
|
||||||
/// Liveness detection confidence (0.0-1.0)
|
|
||||||
final double livenessConfidence;
|
final double livenessConfidence;
|
||||||
|
|
||||||
/// Whether this face matches another face (after comparison)
|
/// Additional face details
|
||||||
final bool isMatch;
|
final Map<String, dynamic>? attributes;
|
||||||
|
|
||||||
/// Match confidence with comparison face (0.0-1.0)
|
|
||||||
final double matchConfidence;
|
|
||||||
|
|
||||||
/// Message providing details about the face status
|
/// Message providing details about the face status
|
||||||
final String message;
|
final String message;
|
||||||
|
|
||||||
/// Creates a FaceModel with the given parameters
|
|
||||||
const FaceModel({
|
const FaceModel({
|
||||||
|
required this.imagePath,
|
||||||
required this.faceId,
|
required this.faceId,
|
||||||
this.sourceImage,
|
this.confidence = 0.0,
|
||||||
this.faceDetails = const {},
|
this.boundingBox = const {'x': 0.0, 'y': 0.0, 'width': 0.0, 'height': 0.0},
|
||||||
this.detectionConfidence = 0.0,
|
this.minAge,
|
||||||
|
this.maxAge,
|
||||||
|
this.gender,
|
||||||
|
this.genderConfidence,
|
||||||
this.isLive = false,
|
this.isLive = false,
|
||||||
this.livenessConfidence = 0.0,
|
this.livenessConfidence = 0.0,
|
||||||
this.isMatch = false,
|
this.attributes,
|
||||||
this.matchConfidence = 0.0,
|
|
||||||
this.message = '',
|
this.message = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Creates a FaceModel from AWS Rekognition detection response
|
/// Constructor from edge function response
|
||||||
factory FaceModel.fromDetection(
|
factory FaceModel.fromEdgeFunction(
|
||||||
String faceId,
|
XFile image,
|
||||||
XFile sourceImage,
|
Map<String, dynamic> faceData,
|
||||||
Map<String, dynamic> detectionData,
|
|
||||||
) {
|
) {
|
||||||
final double confidence = (detectionData['Confidence'] ?? 0.0) / 100.0;
|
// Extract faceId if available
|
||||||
|
final String faceId = faceData['faceId'] ?? faceData['face_id'] ?? '';
|
||||||
|
|
||||||
|
// Extract confidence
|
||||||
|
final double confidence =
|
||||||
|
(faceData['confidence'] ?? faceData['detection_confidence'] ?? 0.0) /
|
||||||
|
100.0;
|
||||||
|
|
||||||
|
// Extract bounding box if available
|
||||||
|
Map<String, double> boundingBox = {
|
||||||
|
'x': 0.0,
|
||||||
|
'y': 0.0,
|
||||||
|
'width': 0.0,
|
||||||
|
'height': 0.0,
|
||||||
|
};
|
||||||
|
if (faceData['boundingBox'] != null || faceData['bounding_box'] != null) {
|
||||||
|
final box = faceData['boundingBox'] ?? faceData['bounding_box'];
|
||||||
|
boundingBox = {
|
||||||
|
'x': (box['left'] ?? box['x'] ?? 0.0).toDouble(),
|
||||||
|
'y': (box['top'] ?? box['y'] ?? 0.0).toDouble(),
|
||||||
|
'width': (box['width'] ?? 0.0).toDouble(),
|
||||||
|
'height': (box['height'] ?? 0.0).toDouble(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract age information if available
|
||||||
|
int? minAge;
|
||||||
|
int? maxAge;
|
||||||
|
if (faceData['age'] != null) {
|
||||||
|
if (faceData['age'] is Map && faceData['age']['range'] != null) {
|
||||||
|
minAge = faceData['age']['range']['low'];
|
||||||
|
maxAge = faceData['age']['range']['high'];
|
||||||
|
} else if (faceData['age'] is num) {
|
||||||
|
// Single age value
|
||||||
|
final age = (faceData['age'] as num).toInt();
|
||||||
|
minAge = age - 5;
|
||||||
|
maxAge = age + 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract gender if available
|
||||||
|
String? gender;
|
||||||
|
double? genderConfidence;
|
||||||
|
if (faceData['gender'] != null) {
|
||||||
|
if (faceData['gender'] is Map) {
|
||||||
|
gender = faceData['gender']['value'];
|
||||||
|
genderConfidence = faceData['gender']['confidence'] / 100.0;
|
||||||
|
} else if (faceData['gender'] is String) {
|
||||||
|
gender = faceData['gender'];
|
||||||
|
genderConfidence = 0.9; // Default confidence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the face model
|
||||||
return FaceModel(
|
return FaceModel(
|
||||||
|
imagePath: image.path,
|
||||||
faceId: faceId,
|
faceId: faceId,
|
||||||
sourceImage: sourceImage,
|
confidence: confidence,
|
||||||
faceDetails: detectionData,
|
boundingBox: boundingBox,
|
||||||
detectionConfidence: confidence,
|
minAge: minAge,
|
||||||
message:
|
maxAge: maxAge,
|
||||||
'Face detected with ${(confidence * 100).toStringAsFixed(1)}% confidence',
|
gender: gender,
|
||||||
|
genderConfidence: genderConfidence,
|
||||||
|
attributes: faceData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an empty FaceModel with no data
|
||||||
|
factory FaceModel.empty() {
|
||||||
|
return const FaceModel(
|
||||||
|
imagePath: '',
|
||||||
|
faceId: '',
|
||||||
|
message: 'No face data available',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -67,33 +135,17 @@ class FaceModel {
|
||||||
String? message,
|
String? message,
|
||||||
}) {
|
}) {
|
||||||
return FaceModel(
|
return FaceModel(
|
||||||
|
imagePath: imagePath,
|
||||||
faceId: faceId,
|
faceId: faceId,
|
||||||
sourceImage: sourceImage,
|
confidence: this.confidence,
|
||||||
faceDetails: faceDetails,
|
boundingBox: boundingBox,
|
||||||
detectionConfidence: detectionConfidence,
|
minAge: minAge,
|
||||||
|
maxAge: maxAge,
|
||||||
|
gender: gender,
|
||||||
|
genderConfidence: genderConfidence,
|
||||||
isLive: isLive,
|
isLive: isLive,
|
||||||
livenessConfidence: confidence,
|
livenessConfidence: confidence,
|
||||||
isMatch: isMatch,
|
attributes: attributes,
|
||||||
matchConfidence: matchConfidence,
|
|
||||||
message: message ?? this.message,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a FaceModel with match details
|
|
||||||
FaceModel withMatch({
|
|
||||||
required bool isMatch,
|
|
||||||
required double confidence,
|
|
||||||
String? message,
|
|
||||||
}) {
|
|
||||||
return FaceModel(
|
|
||||||
faceId: faceId,
|
|
||||||
sourceImage: sourceImage,
|
|
||||||
faceDetails: faceDetails,
|
|
||||||
detectionConfidence: detectionConfidence,
|
|
||||||
isLive: isLive,
|
|
||||||
livenessConfidence: livenessConfidence,
|
|
||||||
isMatch: isMatch,
|
|
||||||
matchConfidence: confidence,
|
|
||||||
message: message ?? this.message,
|
message: message ?? this.message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -101,52 +153,37 @@ class FaceModel {
|
||||||
/// Updates the message for this FaceModel
|
/// Updates the message for this FaceModel
|
||||||
FaceModel withMessage(String newMessage) {
|
FaceModel withMessage(String newMessage) {
|
||||||
return FaceModel(
|
return FaceModel(
|
||||||
|
imagePath: imagePath,
|
||||||
faceId: faceId,
|
faceId: faceId,
|
||||||
sourceImage: sourceImage,
|
confidence: confidence,
|
||||||
faceDetails: faceDetails,
|
boundingBox: boundingBox,
|
||||||
detectionConfidence: detectionConfidence,
|
minAge: minAge,
|
||||||
|
maxAge: maxAge,
|
||||||
|
gender: gender,
|
||||||
|
genderConfidence: genderConfidence,
|
||||||
isLive: isLive,
|
isLive: isLive,
|
||||||
livenessConfidence: livenessConfidence,
|
livenessConfidence: livenessConfidence,
|
||||||
isMatch: isMatch,
|
attributes: attributes,
|
||||||
matchConfidence: matchConfidence,
|
|
||||||
message: newMessage,
|
message: newMessage,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates an empty FaceModel with no data
|
|
||||||
factory FaceModel.empty() {
|
|
||||||
return const FaceModel(faceId: '', message: 'No face data available');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if this FaceModel instance has valid face data
|
/// Checks if this FaceModel instance has valid face data
|
||||||
bool get hasValidFace => faceId.isNotEmpty && detectionConfidence > 0.5;
|
bool get hasValidFace => faceId.isNotEmpty && confidence > 0.5;
|
||||||
|
|
||||||
/// Returns age range if available in faceDetails
|
|
||||||
Map<String, int>? get ageRange {
|
|
||||||
if (faceDetails.containsKey('AgeRange')) {
|
|
||||||
return {
|
|
||||||
'low': faceDetails['AgeRange']['Low'] ?? 0,
|
|
||||||
'high': faceDetails['AgeRange']['High'] ?? 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns gender information if available
|
|
||||||
String? get gender => faceDetails['Gender']?['Value'];
|
|
||||||
|
|
||||||
/// Returns whether the person is smiling if available
|
|
||||||
bool? get isSmiling => faceDetails['Smile']?['Value'];
|
|
||||||
|
|
||||||
/// Returns a map representation of this model
|
/// Returns a map representation of this model
|
||||||
Map<String, dynamic> toMap() {
|
Map<String, dynamic> toMap() {
|
||||||
return {
|
return {
|
||||||
|
'imagePath': imagePath,
|
||||||
'faceId': faceId,
|
'faceId': faceId,
|
||||||
'detectionConfidence': detectionConfidence,
|
'confidence': confidence,
|
||||||
|
'boundingBox': boundingBox,
|
||||||
|
'minAge': minAge,
|
||||||
|
'maxAge': maxAge,
|
||||||
|
'gender': gender,
|
||||||
|
'genderConfidence': genderConfidence,
|
||||||
'isLive': isLive,
|
'isLive': isLive,
|
||||||
'livenessConfidence': livenessConfidence,
|
'livenessConfidence': livenessConfidence,
|
||||||
'isMatch': isMatch,
|
|
||||||
'matchConfidence': matchConfidence,
|
|
||||||
'message': message,
|
'message': message,
|
||||||
'hasValidFace': hasValidFace,
|
'hasValidFace': hasValidFace,
|
||||||
};
|
};
|
||||||
|
@ -179,37 +216,29 @@ class FaceComparisonResult {
|
||||||
required this.message,
|
required this.message,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Creates a FaceComparisonResult from AWS comparison response
|
/// Factory constructor for Edge Function comparison response
|
||||||
factory FaceComparisonResult.fromAwsResponse(
|
factory FaceComparisonResult.fromEdgeFunction(
|
||||||
FaceModel sourceFace,
|
FaceModel sourceFace,
|
||||||
FaceModel targetFace,
|
FaceModel targetFace,
|
||||||
Map<String, dynamic> response,
|
Map<String, dynamic> response,
|
||||||
) {
|
) {
|
||||||
bool isMatch = false;
|
bool isMatch = response['isMatch'] ?? false;
|
||||||
double confidence = 0.0;
|
double confidence = 0.0;
|
||||||
String message = 'Face comparison failed';
|
|
||||||
|
|
||||||
if (response['FaceMatches'] != null && response['FaceMatches'].isNotEmpty) {
|
if (response['confidence'] != null || response['similarity'] != null) {
|
||||||
final match = response['FaceMatches'][0];
|
confidence =
|
||||||
confidence = (match['Similarity'] ?? 0.0) / 100.0;
|
((response['confidence'] ?? response['similarity']) ?? 0.0) / 100.0;
|
||||||
isMatch = confidence >= 0.8; // 80% threshold
|
|
||||||
message =
|
|
||||||
isMatch
|
|
||||||
? 'Face verification successful! Confidence: ${(confidence * 100).toStringAsFixed(1)}%'
|
|
||||||
: 'Face similarity too low: ${(confidence * 100).toStringAsFixed(1)}%';
|
|
||||||
} else {
|
|
||||||
message = 'No matching faces found';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String message =
|
||||||
|
response['message'] ??
|
||||||
|
(isMatch
|
||||||
|
? 'Faces match with ${(confidence * 100).toStringAsFixed(1)}% confidence'
|
||||||
|
: 'Faces do not match');
|
||||||
|
|
||||||
return FaceComparisonResult(
|
return FaceComparisonResult(
|
||||||
sourceFace: sourceFace.withMatch(
|
sourceFace: sourceFace,
|
||||||
isMatch: isMatch,
|
targetFace: targetFace,
|
||||||
confidence: confidence,
|
|
||||||
),
|
|
||||||
targetFace: targetFace.withMatch(
|
|
||||||
isMatch: isMatch,
|
|
||||||
confidence: confidence,
|
|
||||||
),
|
|
||||||
isMatch: isMatch,
|
isMatch: isMatch,
|
||||||
confidence: confidence,
|
confidence: confidence,
|
||||||
message: message,
|
message: message,
|
||||||
|
|
|
@ -0,0 +1,183 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:sigap/src/cores/services/facial_verification_service.dart';
|
||||||
|
import 'package:sigap/src/features/auth/data/models/face_model.dart';
|
||||||
|
|
||||||
|
/// Controller for handling face recognition operations using Edge Functions
|
||||||
|
class FaceRecognitionController extends GetxController {
|
||||||
|
// Singleton instance
|
||||||
|
static FaceRecognitionController get instance => Get.find();
|
||||||
|
|
||||||
|
// Service for face operations - use FacialVerificationService instead of EdgeFunctionService
|
||||||
|
final FacialVerificationService _faceService =
|
||||||
|
FacialVerificationService.instance;
|
||||||
|
|
||||||
|
// Maximum allowed file size in bytes (4MB)
|
||||||
|
final int maxFileSizeBytes = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
// Operation status
|
||||||
|
final RxBool isProcessing = RxBool(false);
|
||||||
|
final RxString processingMessage = RxString('');
|
||||||
|
final RxString errorMessage = RxString('');
|
||||||
|
|
||||||
|
// Face detection results
|
||||||
|
final RxList<FaceModel> detectedFaces = RxList<FaceModel>([]);
|
||||||
|
final Rx<FaceModel> primaryFace = Rx<FaceModel>(FaceModel.empty());
|
||||||
|
|
||||||
|
// Face comparison results
|
||||||
|
final Rx<FaceComparisonResult?> comparisonResult = Rx<FaceComparisonResult?>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Validates an image file for processing
|
||||||
|
Future<bool> validateImageFile(XFile imageFile) async {
|
||||||
|
try {
|
||||||
|
// Check file size
|
||||||
|
final File file = File(imageFile.path);
|
||||||
|
final int fileSize = await file.length();
|
||||||
|
|
||||||
|
if (fileSize > maxFileSizeBytes) {
|
||||||
|
errorMessage.value =
|
||||||
|
'Image size exceeds 4MB limit. Please choose a smaller image or lower resolution.';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
errorMessage.value = 'Error validating image file: $e';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears any previous results and errors
|
||||||
|
void clearResults() {
|
||||||
|
detectedFaces.clear();
|
||||||
|
primaryFace.value = FaceModel.empty();
|
||||||
|
comparisonResult.value = null;
|
||||||
|
errorMessage.value = '';
|
||||||
|
processingMessage.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detects faces in the provided image
|
||||||
|
Future<List<FaceModel>> detectFaces(XFile imageFile) async {
|
||||||
|
try {
|
||||||
|
clearResults();
|
||||||
|
isProcessing.value = true;
|
||||||
|
processingMessage.value = 'Detecting faces...';
|
||||||
|
|
||||||
|
// Validate the image file
|
||||||
|
if (!await validateImageFile(imageFile)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect faces using FacialVerificationService
|
||||||
|
final faces = await _faceService.detectFaces(imageFile);
|
||||||
|
|
||||||
|
if (faces.isEmpty) {
|
||||||
|
errorMessage.value = 'No faces detected in the image.';
|
||||||
|
} else {
|
||||||
|
detectedFaces.assignAll(faces);
|
||||||
|
primaryFace.value = faces.first;
|
||||||
|
processingMessage.value = 'Detected ${faces.length} face(s)';
|
||||||
|
}
|
||||||
|
|
||||||
|
return faces;
|
||||||
|
} catch (e) {
|
||||||
|
errorMessage.value = 'Face detection failed: $e';
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
isProcessing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs liveness check on a selfie image
|
||||||
|
Future<FaceModel> performLivenessCheck(XFile selfieImage) async {
|
||||||
|
try {
|
||||||
|
isProcessing.value = true;
|
||||||
|
processingMessage.value = 'Performing liveness check...';
|
||||||
|
|
||||||
|
// Validate the image file
|
||||||
|
if (!await validateImageFile(selfieImage)) {
|
||||||
|
return FaceModel.empty().withLiveness(
|
||||||
|
isLive: false,
|
||||||
|
confidence: 0.0,
|
||||||
|
message: errorMessage.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform liveness check using FacialVerificationService
|
||||||
|
final faceWithLiveness = await _faceService.performLivenessCheck(
|
||||||
|
selfieImage,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update the primary face
|
||||||
|
if (faceWithLiveness.faceId.isNotEmpty) {
|
||||||
|
primaryFace.value = faceWithLiveness;
|
||||||
|
if (faceWithLiveness.isLive) {
|
||||||
|
processingMessage.value = 'Liveness check passed';
|
||||||
|
} else {
|
||||||
|
errorMessage.value = faceWithLiveness.message;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errorMessage.value = 'No face detected for liveness check';
|
||||||
|
}
|
||||||
|
|
||||||
|
return faceWithLiveness;
|
||||||
|
} catch (e) {
|
||||||
|
errorMessage.value = 'Liveness check failed: $e';
|
||||||
|
return FaceModel.empty().withLiveness(
|
||||||
|
isLive: false,
|
||||||
|
confidence: 0.0,
|
||||||
|
message: 'Error: ${e.toString()}',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
isProcessing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compares two face images
|
||||||
|
Future<FaceComparisonResult> compareFaces(
|
||||||
|
XFile sourceImage,
|
||||||
|
XFile targetImage,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
isProcessing.value = true;
|
||||||
|
processingMessage.value = 'Comparing faces...';
|
||||||
|
|
||||||
|
// Validate both images
|
||||||
|
if (!await validateImageFile(sourceImage) ||
|
||||||
|
!await validateImageFile(targetImage)) {
|
||||||
|
return FaceComparisonResult.error(
|
||||||
|
FaceModel.empty(),
|
||||||
|
FaceModel.empty(),
|
||||||
|
errorMessage.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare faces using FacialVerificationService
|
||||||
|
final result = await _faceService.compareFaces(sourceImage, targetImage);
|
||||||
|
|
||||||
|
// Store the result
|
||||||
|
comparisonResult.value = result;
|
||||||
|
|
||||||
|
if (result.isMatch) {
|
||||||
|
processingMessage.value = 'Face verification successful';
|
||||||
|
} else {
|
||||||
|
errorMessage.value = result.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
errorMessage.value = 'Face comparison failed: $e';
|
||||||
|
return FaceComparisonResult.error(
|
||||||
|
FaceModel.empty(),
|
||||||
|
FaceModel.empty(),
|
||||||
|
'Error: ${e.toString()}',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
isProcessing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,160 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:sigap/src/features/auth/data/models/administrative_division.dart';
|
||||||
|
import 'package:sigap/src/features/map/data/repositories/location_repository.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/city_selection.dart';
|
||||||
|
|
||||||
|
class LocationSelectionController extends GetxController {
|
||||||
|
final AdiminstrativeLocationRepository _AdiminstrativeLocationRepository =
|
||||||
|
AdiminstrativeLocationRepository();
|
||||||
|
|
||||||
|
final RxList<AdministrativeDivision> divisions =
|
||||||
|
<AdministrativeDivision>[].obs;
|
||||||
|
final RxList<AdministrativeDivision> filteredDivisions =
|
||||||
|
<AdministrativeDivision>[].obs;
|
||||||
|
final RxBool isLoading = false.obs;
|
||||||
|
final RxBool searchActive = false.obs;
|
||||||
|
|
||||||
|
// For search functionality
|
||||||
|
final TextEditingController searchController = TextEditingController();
|
||||||
|
final RxList<AdministrativeDivision> searchResults =
|
||||||
|
<AdministrativeDivision>[].obs;
|
||||||
|
|
||||||
|
// Current selection tracking
|
||||||
|
Rx<DivisionType> currentDivisionType = DivisionType.province.obs;
|
||||||
|
RxString currentParentId = RxString('');
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
searchController.addListener(_onSearchChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onClose() {
|
||||||
|
searchController.removeListener(_onSearchChanged);
|
||||||
|
searchController.dispose();
|
||||||
|
super.onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSearchChanged() {
|
||||||
|
if (searchController.text.isEmpty && searchActive.value) {
|
||||||
|
searchActive.value = false;
|
||||||
|
filteredDivisions.value = divisions;
|
||||||
|
} else if (searchController.text.isNotEmpty) {
|
||||||
|
searchActive.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadDivisions(
|
||||||
|
DivisionType divisionType,
|
||||||
|
String? parentId,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
isLoading.value = true;
|
||||||
|
currentDivisionType.value = divisionType;
|
||||||
|
currentParentId.value = parentId ?? '';
|
||||||
|
|
||||||
|
// Load divisions by type and parent
|
||||||
|
final result = await _AdiminstrativeLocationRepository.getDivisionsByType(
|
||||||
|
divisionType,
|
||||||
|
parentId,
|
||||||
|
);
|
||||||
|
|
||||||
|
divisions.value = result;
|
||||||
|
filteredDivisions.value = result;
|
||||||
|
} catch (e) {
|
||||||
|
print('Error loading divisions: $e');
|
||||||
|
// Show error message if needed
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateSearch(String query) async {
|
||||||
|
if (query.isEmpty) {
|
||||||
|
clearSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchActive.value = true;
|
||||||
|
|
||||||
|
if (query.length < 3) {
|
||||||
|
// Only filter current level for short queries
|
||||||
|
filteredDivisions.value =
|
||||||
|
divisions
|
||||||
|
.where(
|
||||||
|
(division) =>
|
||||||
|
division.name.toLowerCase().contains(query.toLowerCase()),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
} else {
|
||||||
|
// For longer queries, search across all levels
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
final results = await _AdiminstrativeLocationRepository.searchLocations(
|
||||||
|
query,
|
||||||
|
);
|
||||||
|
filteredDivisions.value = results;
|
||||||
|
} catch (e) {
|
||||||
|
print('Error searching locations: $e');
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearSearch() {
|
||||||
|
searchController.clear();
|
||||||
|
searchActive.value = false;
|
||||||
|
filteredDivisions.value = divisions;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> getFullLocationString(AdministrativeDivision division) async {
|
||||||
|
return await _AdiminstrativeLocationRepository.getFullLocationString(
|
||||||
|
division,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> navigateToBreadcrumb(int index, List<String> breadcrumbs) async {
|
||||||
|
// Determine the division type based on breadcrumb level
|
||||||
|
final DivisionType divisionType = _getDivisionTypeForBreadcrumb(index);
|
||||||
|
|
||||||
|
// Get parent ID if appropriate (might be null for province level)
|
||||||
|
String? parentId;
|
||||||
|
if (index > 0) {
|
||||||
|
// We need to find the parent ID based on the previous breadcrumb
|
||||||
|
parentId = await _AdiminstrativeLocationRepository.getIdFromName(
|
||||||
|
breadcrumbs[index - 1],
|
||||||
|
_getDivisionTypeForBreadcrumb(index - 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create shortened breadcrumbs list
|
||||||
|
final List<String> updatedBreadcrumbs = breadcrumbs.sublist(0, index + 1);
|
||||||
|
|
||||||
|
// Navigate to the appropriate location selection page
|
||||||
|
Get.off(
|
||||||
|
() => LocationSelectionPage(
|
||||||
|
initialDivisionType: divisionType,
|
||||||
|
parentId: parentId,
|
||||||
|
breadcrumbs: updatedBreadcrumbs,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DivisionType _getDivisionTypeForBreadcrumb(int index) {
|
||||||
|
switch (index) {
|
||||||
|
case 0:
|
||||||
|
return DivisionType.province;
|
||||||
|
case 1:
|
||||||
|
return DivisionType.regency;
|
||||||
|
case 2:
|
||||||
|
return DivisionType.district;
|
||||||
|
case 3:
|
||||||
|
return DivisionType.village;
|
||||||
|
default:
|
||||||
|
return DivisionType.province;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -333,8 +333,34 @@ class FormRegistrationController extends GetxController {
|
||||||
permanent: false,
|
permanent: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Get extracted ID card data
|
||||||
|
String extractedIdNumber = '';
|
||||||
|
String extractedName = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to get controller if it exists
|
||||||
|
if (Get.isRegistered<IdCardVerificationController>()) {
|
||||||
|
final idCardController = Get.find<IdCardVerificationController>();
|
||||||
|
|
||||||
|
if (idCardController.ktpModel.value != null) {
|
||||||
|
extractedIdNumber = idCardController.ktpModel.value?.nik ?? '';
|
||||||
|
extractedName = idCardController.ktpModel.value?.name ?? '';
|
||||||
|
} else if (idCardController.ktaModel.value != null) {
|
||||||
|
extractedIdNumber = idCardController.ktaModel.value?.nrp ?? '';
|
||||||
|
extractedName = idCardController.ktaModel.value?.name ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('Error getting extracted data: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize identity controller with the extracted data
|
||||||
Get.put<IdentityVerificationController>(
|
Get.put<IdentityVerificationController>(
|
||||||
IdentityVerificationController(isOfficer: isOfficer),
|
IdentityVerificationController(
|
||||||
|
isOfficer: isOfficer,
|
||||||
|
extractedIdCardNumber: extractedIdNumber,
|
||||||
|
extractedName: extractedName,
|
||||||
|
),
|
||||||
permanent: false,
|
permanent: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -434,6 +460,8 @@ class FormRegistrationController extends GetxController {
|
||||||
idCardVerificationController.hasConfirmedIdCard.value) {
|
idCardVerificationController.hasConfirmedIdCard.value) {
|
||||||
// Get the model from the controller
|
// Get the model from the controller
|
||||||
idCardData.value = idCardVerificationController.verifiedIdCardModel;
|
idCardData.value = idCardVerificationController.verifiedIdCardModel;
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error passing ID card data: $e');
|
print('Error passing ID card data: $e');
|
||||||
|
@ -489,16 +517,58 @@ class FormRegistrationController extends GetxController {
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
// Go to next step - fixed implementation
|
||||||
void nextStep() {
|
void nextStep() {
|
||||||
|
// Special case for step 1 (ID Card step)
|
||||||
|
if (currentStep.value == 1) {
|
||||||
|
// Log step status
|
||||||
|
Logger().d(
|
||||||
|
'ID Card step: confirmStatus=${idCardVerificationController.hasConfirmedIdCard.value}',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ensure ID card is confirmed before allowing to proceed
|
||||||
|
if (!idCardVerificationController.hasConfirmedIdCard.value) {
|
||||||
|
// Show a message that user needs to confirm the ID card first
|
||||||
|
TLoaders.errorSnackBar(
|
||||||
|
title: 'Action Required',
|
||||||
|
message: 'Please confirm your ID card image before proceeding.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass data and proceed
|
||||||
|
passIdCardDataToNextStep();
|
||||||
|
currentStep.value++; // Directly increment step
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Special case for step 2 (Selfie Verification step)
|
||||||
|
else if (currentStep.value == 2) {
|
||||||
|
// Log step status
|
||||||
|
Logger().d(
|
||||||
|
'Selfie step: confirmStatus=${selfieVerificationController.hasConfirmedSelfie.value}',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ensure selfie is confirmed before allowing to proceed
|
||||||
|
if (!selfieVerificationController.hasConfirmedSelfie.value) {
|
||||||
|
// Show a message that user needs to confirm the selfie first
|
||||||
|
TLoaders.errorSnackBar(
|
||||||
|
title: 'Action Required',
|
||||||
|
message: 'Please confirm your selfie image before proceeding.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proceed to next step
|
||||||
|
currentStep.value++; // Directly increment step
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For all other steps, perform standard validation
|
||||||
if (!validateCurrentStep()) return;
|
if (!validateCurrentStep()) return;
|
||||||
|
|
||||||
|
// Proceed to next step
|
||||||
if (currentStep.value < totalSteps - 1) {
|
if (currentStep.value < totalSteps - 1) {
|
||||||
currentStep.value++;
|
currentStep.value++;
|
||||||
|
|
||||||
if (currentStep.value == 1) {
|
|
||||||
// Pass ID card data to the next step
|
|
||||||
passIdCardDataToNextStep();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
submitForm();
|
submitForm();
|
||||||
}
|
}
|
||||||
|
@ -686,4 +756,19 @@ class FormRegistrationController extends GetxController {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _initializeIdentityVerificationStep() {
|
||||||
|
// Get extracted data from previous step if available
|
||||||
|
String extractedIdCardNumber = '';
|
||||||
|
String extractedName = '';
|
||||||
|
|
||||||
|
final idCardController = Get.find<IdCardVerificationController>();
|
||||||
|
if (idCardController.ktpModel.value != null) {
|
||||||
|
extractedIdCardNumber = idCardController.ktpModel.value?.nik ?? '';
|
||||||
|
extractedName = idCardController.ktpModel.value?.name ?? '';
|
||||||
|
} else if (idCardController.ktaModel.value != null) {
|
||||||
|
extractedIdCardNumber = idCardController.ktaModel.value?.nrp ?? '';
|
||||||
|
extractedName = idCardController.ktaModel.value?.name ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,8 +2,8 @@ import 'dart:io';
|
||||||
|
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:sigap/src/cores/services/aws_rekognition_service.dart';
|
|
||||||
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
|
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
|
||||||
|
import 'package:sigap/src/cores/services/facial_verification_service.dart';
|
||||||
import 'package:sigap/src/features/auth/data/models/face_model.dart';
|
import 'package:sigap/src/features/auth/data/models/face_model.dart';
|
||||||
import 'package:sigap/src/features/auth/data/models/kta_model.dart';
|
import 'package:sigap/src/features/auth/data/models/kta_model.dart';
|
||||||
import 'package:sigap/src/features/auth/data/models/ktp_model.dart';
|
import 'package:sigap/src/features/auth/data/models/ktp_model.dart';
|
||||||
|
@ -14,8 +14,9 @@ class IdCardVerificationController extends GetxController {
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
final AzureOCRService _ocrService = AzureOCRService();
|
final AzureOCRService _ocrService = AzureOCRService();
|
||||||
// Using AWS for face recognition
|
// Using FacialVerificationService instead of direct EdgeFunction
|
||||||
final AwsRecognitionService _faceService = AwsRecognitionService.instance;
|
final FacialVerificationService _faceService =
|
||||||
|
FacialVerificationService.instance;
|
||||||
|
|
||||||
final bool isOfficer;
|
final bool isOfficer;
|
||||||
|
|
||||||
|
@ -173,10 +174,28 @@ class IdCardVerificationController extends GetxController {
|
||||||
ktpModel.value = _ocrService.createKtpModel(result);
|
ktpModel.value = _ocrService.createKtpModel(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to detect faces in the ID card image using AWS Rekognition
|
// Try to detect faces in the ID card image using FacialVerificationService
|
||||||
if (isImageValid) {
|
if (isImageValid) {
|
||||||
try {
|
try {
|
||||||
// Use AWS Rekognition to detect faces
|
// Skip actual face detection in development mode
|
||||||
|
if (_faceService.skipFaceVerification) {
|
||||||
|
// Create dummy face detection result
|
||||||
|
idCardFace.value = FaceModel(
|
||||||
|
imagePath: idCardImage.value!.path,
|
||||||
|
faceId:
|
||||||
|
'dummy-id-card-face-${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
confidence: 0.95,
|
||||||
|
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
|
||||||
|
);
|
||||||
|
|
||||||
|
// For backward compatibility
|
||||||
|
idCardFaceId.value = idCardFace.value.faceId;
|
||||||
|
hasFaceDetected.value = true;
|
||||||
|
print(
|
||||||
|
'Dummy face detected in ID card: ${idCardFace.value.faceId}',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Use FacialVerificationService to detect faces
|
||||||
final faces = await _faceService.detectFaces(idCardImage.value!);
|
final faces = await _faceService.detectFaces(idCardImage.value!);
|
||||||
if (faces.isNotEmpty) {
|
if (faces.isNotEmpty) {
|
||||||
// Store the face model
|
// Store the face model
|
||||||
|
@ -187,6 +206,7 @@ class IdCardVerificationController extends GetxController {
|
||||||
hasFaceDetected.value = idCardFace.value.hasValidFace;
|
hasFaceDetected.value = idCardFace.value.hasValidFace;
|
||||||
print('Face detected in ID card: ${idCardFace.value.faceId}');
|
print('Face detected in ID card: ${idCardFace.value.faceId}');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (faceError) {
|
} catch (faceError) {
|
||||||
print('Face detection failed: $faceError');
|
print('Face detection failed: $faceError');
|
||||||
// Don't fail validation if face detection fails
|
// Don't fail validation if face detection fails
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/cores/services/aws_rekognition_service.dart';
|
|
||||||
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
|
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
|
||||||
|
import 'package:sigap/src/cores/services/facial_verification_service.dart';
|
||||||
|
// Remove AWS rekognition import completely
|
||||||
import 'package:sigap/src/features/auth/data/models/face_model.dart';
|
import 'package:sigap/src/features/auth/data/models/face_model.dart';
|
||||||
import 'package:sigap/src/features/auth/data/models/kta_model.dart';
|
import 'package:sigap/src/features/auth/data/models/kta_model.dart';
|
||||||
import 'package:sigap/src/features/auth/data/models/ktp_model.dart';
|
import 'package:sigap/src/features/auth/data/models/ktp_model.dart';
|
||||||
|
@ -16,8 +17,9 @@ class IdentityVerificationController extends GetxController {
|
||||||
// Dependencies
|
// Dependencies
|
||||||
final bool isOfficer;
|
final bool isOfficer;
|
||||||
final AzureOCRService _ocrService = AzureOCRService();
|
final AzureOCRService _ocrService = AzureOCRService();
|
||||||
// Use AWS Rekognition for face detection instead of Azure Face API
|
// Use FacialVerificationService instead of direct EdgeFunction
|
||||||
final AwsRecognitionService _faceService = AwsRecognitionService.instance;
|
final FacialVerificationService _faceService =
|
||||||
|
FacialVerificationService.instance;
|
||||||
|
|
||||||
// Controllers
|
// Controllers
|
||||||
final TextEditingController nikController = TextEditingController();
|
final TextEditingController nikController = TextEditingController();
|
||||||
|
@ -48,8 +50,8 @@ class IdentityVerificationController extends GetxController {
|
||||||
final Rx<FaceComparisonResult?> faceComparisonResult =
|
final Rx<FaceComparisonResult?> faceComparisonResult =
|
||||||
Rx<FaceComparisonResult?>(null);
|
Rx<FaceComparisonResult?>(null);
|
||||||
|
|
||||||
// Gender selection
|
// Gender selection - initialize with a default value
|
||||||
final Rx<String?> selectedGender = Rx<String?>(null);
|
final Rx<String?> selectedGender = Rx<String?>('Male');
|
||||||
|
|
||||||
// Form validation
|
// Form validation
|
||||||
final RxBool isFormValid = RxBool(true);
|
final RxBool isFormValid = RxBool(true);
|
||||||
|
@ -57,12 +59,24 @@ class IdentityVerificationController extends GetxController {
|
||||||
// Flag to prevent infinite loop
|
// Flag to prevent infinite loop
|
||||||
bool _isApplyingData = false;
|
bool _isApplyingData = false;
|
||||||
|
|
||||||
IdentityVerificationController({required this.isOfficer});
|
// Properties to store extracted ID card data
|
||||||
|
final String? extractedIdCardNumber;
|
||||||
|
final String? extractedName;
|
||||||
|
final RxBool isPreFilledNik = false.obs;
|
||||||
|
|
||||||
|
IdentityVerificationController({
|
||||||
|
this.extractedIdCardNumber = '',
|
||||||
|
this.extractedName = '',
|
||||||
|
required this.isOfficer,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
super.onInit();
|
super.onInit();
|
||||||
// Delay data application to avoid initialization issues
|
// Make sure selectedGender has a default value
|
||||||
|
selectedGender.value = selectedGender.value ?? 'Male';
|
||||||
|
|
||||||
|
// Try to apply ID card data after initialization
|
||||||
Future.microtask(() => _safeApplyIdCardData());
|
Future.microtask(() => _safeApplyIdCardData());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -181,11 +195,6 @@ class IdentityVerificationController extends GetxController {
|
||||||
isFormValid.value = false;
|
isFormValid.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedGender.value == null) {
|
|
||||||
genderError.value = 'Please select your gender';
|
|
||||||
isFormValid.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (addressController.text.isEmpty) {
|
if (addressController.text.isEmpty) {
|
||||||
addressError.value = 'Address is required';
|
addressError.value = 'Address is required';
|
||||||
isFormValid.value = false;
|
isFormValid.value = false;
|
||||||
|
@ -295,8 +304,43 @@ class IdentityVerificationController extends GetxController {
|
||||||
return matches >= (parts1.length / 2).floor();
|
return matches >= (parts1.length / 2).floor();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Face verification function using AWS Rekognition instead of Azure
|
// Face verification function using EdgeFunction instead of AWS directly
|
||||||
void verifyFaceMatch() {
|
void verifyFaceMatch() {
|
||||||
|
// Set quick verification status for development
|
||||||
|
if (_faceService.skipFaceVerification) {
|
||||||
|
isFaceVerified.value = true;
|
||||||
|
faceVerificationMessage.value =
|
||||||
|
'Face verification skipped (development mode)';
|
||||||
|
|
||||||
|
// Create dummy comparison result
|
||||||
|
final idCardController = Get.find<IdCardVerificationController>();
|
||||||
|
final selfieController = Get.find<SelfieVerificationController>();
|
||||||
|
|
||||||
|
if (idCardController.idCardImage.value != null &&
|
||||||
|
selfieController.selfieImage.value != null) {
|
||||||
|
// Set dummy result
|
||||||
|
faceComparisonResult.value = FaceComparisonResult(
|
||||||
|
sourceFace: FaceModel(
|
||||||
|
imagePath: idCardController.idCardImage.value!.path,
|
||||||
|
faceId: 'dummy-id-card-id',
|
||||||
|
confidence: 0.95,
|
||||||
|
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
|
||||||
|
),
|
||||||
|
targetFace: FaceModel(
|
||||||
|
imagePath: selfieController.selfieImage.value!.path,
|
||||||
|
faceId: 'dummy-selfie-id',
|
||||||
|
confidence: 0.95,
|
||||||
|
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
|
||||||
|
),
|
||||||
|
isMatch: true,
|
||||||
|
confidence: 0.92,
|
||||||
|
message: 'Face verification passed (development mode)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
isVerifyingFace.value = true;
|
isVerifyingFace.value = true;
|
||||||
|
|
||||||
// Get ID card and selfie images
|
// Get ID card and selfie images
|
||||||
|
@ -314,7 +358,7 @@ class IdentityVerificationController extends GetxController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use AWS Rekognition to compare faces
|
// Use FacialVerificationService to compare faces
|
||||||
_faceService
|
_faceService
|
||||||
.compareFaces(
|
.compareFaces(
|
||||||
idCardController.idCardImage.value!,
|
idCardController.idCardImage.value!,
|
||||||
|
@ -359,4 +403,17 @@ class IdentityVerificationController extends GetxController {
|
||||||
addressController.dispose();
|
addressController.dispose();
|
||||||
super.onClose();
|
super.onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Method to pre-fill NIK and Name from the extracted data
|
||||||
|
void prefillExtractedData() {
|
||||||
|
if (extractedIdCardNumber != null && extractedIdCardNumber!.isNotEmpty) {
|
||||||
|
nikController.text = extractedIdCardNumber!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extractedName != null && extractedName!.isNotEmpty) {
|
||||||
|
fullNameController.text = extractedName!;
|
||||||
|
}
|
||||||
|
|
||||||
|
isPreFilledNik.value = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@ import 'dart:io';
|
||||||
|
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:sigap/src/cores/services/aws_rekognition_service.dart';
|
import 'package:sigap/src/cores/services/facial_verification_service.dart';
|
||||||
import 'package:sigap/src/features/auth/data/models/face_model.dart';
|
import 'package:sigap/src/features/auth/data/models/face_model.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart';
|
||||||
|
|
||||||
|
@ -10,8 +10,9 @@ class SelfieVerificationController extends GetxController {
|
||||||
// Singleton instance
|
// Singleton instance
|
||||||
static SelfieVerificationController get instance => Get.find();
|
static SelfieVerificationController get instance => Get.find();
|
||||||
|
|
||||||
// Services - Use AWS Rekognition
|
// Services - Use FacialVerificationService instead of direct EdgeFunction
|
||||||
final AwsRecognitionService _faceService = AwsRecognitionService.instance;
|
final FacialVerificationService _faceService =
|
||||||
|
FacialVerificationService.instance;
|
||||||
|
|
||||||
// Maximum allowed file size in bytes (4MB)
|
// Maximum allowed file size in bytes (4MB)
|
||||||
final int maxFileSizeBytes = 4 * 1024 * 1024; // 4MB in bytes
|
final int maxFileSizeBytes = 4 * 1024 * 1024; // 4MB in bytes
|
||||||
|
@ -123,7 +124,7 @@ class SelfieVerificationController extends GetxController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial validation of selfie image using AWS Rekognition
|
// Initial validation of selfie image using FacialVerificationService
|
||||||
Future<void> validateSelfieImage() async {
|
Future<void> validateSelfieImage() async {
|
||||||
// Clear previous validation messages
|
// Clear previous validation messages
|
||||||
clearErrors();
|
clearErrors();
|
||||||
|
@ -134,10 +135,36 @@ class SelfieVerificationController extends GetxController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Quick validation for development
|
||||||
|
if (_faceService.skipFaceVerification) {
|
||||||
|
isSelfieValid.value = true;
|
||||||
|
isLivenessCheckPassed.value = true;
|
||||||
|
selfieImageFaceId.value =
|
||||||
|
'dummy-face-id-${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
selfieValidationMessage.value =
|
||||||
|
'Selfie validation successful (development mode)';
|
||||||
|
|
||||||
|
// Add dummy face data
|
||||||
|
selfieFace.value = FaceModel(
|
||||||
|
imagePath: selfieImage.value!.path,
|
||||||
|
faceId: selfieImageFaceId.value,
|
||||||
|
confidence: 0.95,
|
||||||
|
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
|
||||||
|
).withLiveness(
|
||||||
|
isLive: true,
|
||||||
|
confidence: 0.92,
|
||||||
|
message: 'Liveness check passed (development mode)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also do dummy comparison with ID Card
|
||||||
|
await compareWithIDCardPhoto();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isVerifyingFace.value = true;
|
isVerifyingFace.value = true;
|
||||||
|
|
||||||
// Use AWS Rekognition for liveness check
|
// Use FacialVerificationService for liveness check
|
||||||
final FaceModel livenessFace = await _faceService.performLivenessCheck(
|
final FaceModel livenessFace = await _faceService.performLivenessCheck(
|
||||||
selfieImage.value!,
|
selfieImage.value!,
|
||||||
);
|
);
|
||||||
|
@ -169,7 +196,7 @@ class SelfieVerificationController extends GetxController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compare selfie with ID card photo using AWS Rekognition
|
// Compare selfie with ID card photo using FacialVerificationService
|
||||||
Future<void> compareWithIDCardPhoto() async {
|
Future<void> compareWithIDCardPhoto() async {
|
||||||
try {
|
try {
|
||||||
final idCardController = Get.find<IdCardVerificationController>();
|
final idCardController = Get.find<IdCardVerificationController>();
|
||||||
|
@ -183,7 +210,47 @@ class SelfieVerificationController extends GetxController {
|
||||||
|
|
||||||
isComparingWithIDCard.value = true;
|
isComparingWithIDCard.value = true;
|
||||||
|
|
||||||
// Use AWS Rekognition to compare the faces
|
// Quick comparison for development
|
||||||
|
if (_faceService.skipFaceVerification) {
|
||||||
|
// Create dummy successful comparison result
|
||||||
|
final comparisonResult = FaceComparisonResult(
|
||||||
|
sourceFace:
|
||||||
|
idCardController.idCardFace.value.hasValidFace
|
||||||
|
? idCardController.idCardFace.value
|
||||||
|
: FaceModel(
|
||||||
|
imagePath: idCardController.idCardImage.value!.path,
|
||||||
|
faceId:
|
||||||
|
'dummy-id-card-face-${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
confidence: 0.95,
|
||||||
|
boundingBox: {
|
||||||
|
'x': 0.1,
|
||||||
|
'y': 0.1,
|
||||||
|
'width': 0.8,
|
||||||
|
'height': 0.8,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
targetFace: selfieFace.value,
|
||||||
|
isMatch: true,
|
||||||
|
confidence: 0.91,
|
||||||
|
message: 'Face matching successful (development mode)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store the comparison result
|
||||||
|
faceComparisonResult.value = comparisonResult;
|
||||||
|
|
||||||
|
// For backward compatibility
|
||||||
|
isMatchWithIDCard.value = true;
|
||||||
|
matchConfidence.value = 0.91;
|
||||||
|
|
||||||
|
// Update validation message
|
||||||
|
selfieValidationMessage.value =
|
||||||
|
'Face matching successful (development mode)';
|
||||||
|
|
||||||
|
isComparingWithIDCard.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use FacialVerificationService to compare the faces
|
||||||
final comparisonResult = await _faceService.compareFaces(
|
final comparisonResult = await _faceService.compareFaces(
|
||||||
idCardController.idCardImage.value!,
|
idCardController.idCardImage.value!,
|
||||||
selfieImage.value!
|
selfieImage.value!
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart';
|
|
||||||
import 'package:sigap/src/features/auth/data/models/kta_model.dart';
|
import 'package:sigap/src/features/auth/data/models/kta_model.dart';
|
||||||
import 'package:sigap/src/features/auth/data/models/ktp_model.dart';
|
import 'package:sigap/src/features/auth/data/models/ktp_model.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/shared/widgets/image_upload/image_source_dialog.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/image_upload/image_uploader.dart';
|
||||||
import 'package:sigap/src/shared/widgets/info/tips_container.dart';
|
import 'package:sigap/src/shared/widgets/info/tips_container.dart';
|
||||||
|
@ -27,8 +27,6 @@ class IdCardVerificationStep extends StatelessWidget {
|
||||||
final isOfficer = mainController.selectedRole.value?.isOfficer ?? false;
|
final isOfficer = mainController.selectedRole.value?.isOfficer ?? false;
|
||||||
final String idCardType = isOfficer ? 'KTA' : 'KTP';
|
final String idCardType = isOfficer ? 'KTA' : 'KTP';
|
||||||
|
|
||||||
final isShow = controller.isIdCardValid.value;
|
|
||||||
|
|
||||||
return Form(
|
return Form(
|
||||||
key: formKey,
|
key: formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
|
@ -44,7 +42,7 @@ class IdCardVerificationStep extends StatelessWidget {
|
||||||
padding: const EdgeInsets.only(top: TSizes.sm),
|
padding: const EdgeInsets.only(top: TSizes.sm),
|
||||||
child: Text(
|
child: Text(
|
||||||
controller.idCardError.value,
|
controller.idCardError.value,
|
||||||
style: const TextStyle(color: Colors.red),
|
style: TextStyle(color: TColors.error),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: const SizedBox.shrink(),
|
: const SizedBox.shrink(),
|
||||||
|
@ -82,11 +80,13 @@ class IdCardVerificationStep extends StatelessWidget {
|
||||||
return _buildKtaResultCard(
|
return _buildKtaResultCard(
|
||||||
controller.ktaModel.value!,
|
controller.ktaModel.value!,
|
||||||
controller.isIdCardValid.value,
|
controller.isIdCardValid.value,
|
||||||
|
context,
|
||||||
);
|
);
|
||||||
} else if (!isOfficer && controller.ktpModel.value != null) {
|
} else if (!isOfficer && controller.ktpModel.value != null) {
|
||||||
return _buildKtpResultCard(
|
return _buildKtpResultCard(
|
||||||
controller.ktpModel.value!,
|
controller.ktpModel.value!,
|
||||||
controller.isIdCardValid.value,
|
controller.isIdCardValid.value,
|
||||||
|
context,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Fallback to the regular OCR result card
|
// Fallback to the regular OCR result card
|
||||||
|
@ -138,25 +138,23 @@ class IdCardVerificationStep extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'$idCardType Verification',
|
'$idCardType Verification',
|
||||||
style: TextStyle(
|
style: Theme.of(
|
||||||
fontSize: TSizes.fontSizeLg,
|
context,
|
||||||
|
).textTheme.headlineSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: TColors.textPrimary,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: TSizes.sm),
|
const SizedBox(height: TSizes.sm),
|
||||||
Text(
|
Text(
|
||||||
'Upload a clear image of your $idCardType',
|
'Upload a clear image of your $idCardType',
|
||||||
style: Theme.of(
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
context,
|
|
||||||
).textTheme.headlineSmall?.copyWith(color: TColors.textSecondary),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: TSizes.xs),
|
const SizedBox(height: TSizes.xs),
|
||||||
Text(
|
Text(
|
||||||
'Make sure all text and your photo are clearly visible',
|
'Make sure all text and your photo are clearly visible',
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.labelLarge?.copyWith(color: TColors.textSecondary),
|
).textTheme.bodySmall?.copyWith(fontStyle: FontStyle.italic),
|
||||||
),
|
),
|
||||||
const SizedBox(height: TSizes.spaceBtwItems),
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
],
|
],
|
||||||
|
@ -173,10 +171,10 @@ class IdCardVerificationStep extends StatelessWidget {
|
||||||
"Your photo and all text should be clearly visible",
|
"Your photo and all text should be clearly visible",
|
||||||
"Avoid using flash to prevent glare",
|
"Avoid using flash to prevent glare",
|
||||||
],
|
],
|
||||||
backgroundColor: Colors.blue,
|
backgroundColor: TColors.primary.withOpacity(0.1),
|
||||||
textColor: Colors.blue.shade800,
|
textColor: TColors.primary,
|
||||||
iconColor: Colors.blue,
|
iconColor: TColors.primary,
|
||||||
borderColor: Colors.blue,
|
borderColor: TColors.primary.withOpacity(0.3),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -195,7 +193,11 @@ class IdCardVerificationStep extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildKtpResultCard(KtpModel model, bool isValid) {
|
Widget _buildKtpResultCard(
|
||||||
|
KtpModel model,
|
||||||
|
bool isValid,
|
||||||
|
BuildContext context,
|
||||||
|
) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: TSizes.spaceBtwItems),
|
padding: const EdgeInsets.symmetric(vertical: TSizes.spaceBtwItems),
|
||||||
child: Card(
|
child: Card(
|
||||||
|
@ -203,7 +205,7 @@ class IdCardVerificationStep extends StatelessWidget {
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
||||||
side: BorderSide(
|
side: BorderSide(
|
||||||
color: isValid ? Colors.green : Colors.orange,
|
color: isValid ? Colors.green : TColors.warning,
|
||||||
width: 1.5,
|
width: 1.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -212,21 +214,22 @@ class IdCardVerificationStep extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildCardHeader('KTP', isValid),
|
_buildCardHeader('KTP', isValid, context),
|
||||||
const Divider(height: TSizes.spaceBtwItems),
|
const Divider(height: TSizes.spaceBtwItems),
|
||||||
|
|
||||||
// Display KTP details
|
// Display KTP details
|
||||||
if (model.nik.isNotEmpty)
|
if (model.nik.isNotEmpty)
|
||||||
_buildInfoRow('NIK', model.formattedNik),
|
_buildInfoRow('NIK', model.formattedNik, context),
|
||||||
if (model.name.isNotEmpty) _buildInfoRow('Name', model.name),
|
if (model.name.isNotEmpty)
|
||||||
|
_buildInfoRow('Name', model.name, context),
|
||||||
if (model.birthPlace.isNotEmpty)
|
if (model.birthPlace.isNotEmpty)
|
||||||
_buildInfoRow('Birth Place', model.birthPlace),
|
_buildInfoRow('Birth Place', model.birthPlace, context),
|
||||||
if (model.birthDate.isNotEmpty)
|
if (model.birthDate.isNotEmpty)
|
||||||
_buildInfoRow('Birth Date', model.birthDate),
|
_buildInfoRow('Birth Date', model.birthDate, context),
|
||||||
if (model.gender.isNotEmpty)
|
if (model.gender.isNotEmpty)
|
||||||
_buildInfoRow('Gender', model.gender),
|
_buildInfoRow('Gender', model.gender, context),
|
||||||
if (model.address.isNotEmpty)
|
if (model.address.isNotEmpty)
|
||||||
_buildInfoRow('Address', model.address),
|
_buildInfoRow('Address', model.address, context),
|
||||||
|
|
||||||
if (!isValid) _buildDataWarning(),
|
if (!isValid) _buildDataWarning(),
|
||||||
],
|
],
|
||||||
|
@ -236,7 +239,11 @@ class IdCardVerificationStep extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildKtaResultCard(KtaModel model, bool isValid) {
|
Widget _buildKtaResultCard(
|
||||||
|
KtaModel model,
|
||||||
|
bool isValid,
|
||||||
|
BuildContext context,
|
||||||
|
) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: TSizes.spaceBtwItems),
|
padding: const EdgeInsets.symmetric(vertical: TSizes.spaceBtwItems),
|
||||||
child: Card(
|
child: Card(
|
||||||
|
@ -244,7 +251,7 @@ class IdCardVerificationStep extends StatelessWidget {
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
||||||
side: BorderSide(
|
side: BorderSide(
|
||||||
color: isValid ? Colors.green : Colors.orange,
|
color: isValid ? Colors.green : TColors.warning,
|
||||||
width: 1.5,
|
width: 1.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -253,31 +260,33 @@ class IdCardVerificationStep extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildCardHeader('KTA', isValid),
|
_buildCardHeader('KTA', isValid, context),
|
||||||
const Divider(height: TSizes.spaceBtwItems),
|
const Divider(height: TSizes.spaceBtwItems),
|
||||||
|
|
||||||
// Display KTA details
|
// Display KTA details
|
||||||
if (model.name.isNotEmpty) _buildInfoRow('Name', model.name),
|
if (model.name.isNotEmpty)
|
||||||
|
_buildInfoRow('Name', model.name, context),
|
||||||
if (model.nrp.isNotEmpty)
|
if (model.nrp.isNotEmpty)
|
||||||
_buildInfoRow('NRP', model.formattedNrp),
|
_buildInfoRow('NRP', model.formattedNrp, context),
|
||||||
if (model.policeUnit.isNotEmpty)
|
if (model.policeUnit.isNotEmpty)
|
||||||
_buildInfoRow('Unit', model.policeUnit),
|
_buildInfoRow('Unit', model.policeUnit, context),
|
||||||
|
|
||||||
// Get extra data
|
// Get extra data
|
||||||
if (model.extraData != null) ...[
|
if (model.extraData != null) ...[
|
||||||
if (model.extraData!['pangkat'] != null)
|
if (model.extraData!['pangkat'] != null)
|
||||||
_buildInfoRow('Rank', model.extraData!['pangkat']),
|
_buildInfoRow('Rank', model.extraData!['pangkat'], context),
|
||||||
if (model.extraData!['tanggal_lahir'] != null)
|
if (model.extraData!['tanggal_lahir'] != null)
|
||||||
_buildInfoRow(
|
_buildInfoRow(
|
||||||
'Birth Date',
|
'Birth Date',
|
||||||
model.extraData!['tanggal_lahir'],
|
model.extraData!['tanggal_lahir'],
|
||||||
|
context,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
if (model.issueDate.isNotEmpty)
|
if (model.issueDate.isNotEmpty)
|
||||||
_buildInfoRow('Issue Date', model.issueDate),
|
_buildInfoRow('Issue Date', model.issueDate, context),
|
||||||
if (model.cardNumber.isNotEmpty)
|
if (model.cardNumber.isNotEmpty)
|
||||||
_buildInfoRow('Card Number', model.cardNumber),
|
_buildInfoRow('Card Number', model.cardNumber, context),
|
||||||
|
|
||||||
if (!isValid) _buildDataWarning(),
|
if (!isValid) _buildDataWarning(),
|
||||||
],
|
],
|
||||||
|
@ -287,21 +296,22 @@ class IdCardVerificationStep extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCardHeader(String cardType, bool isValid) {
|
Widget _buildCardHeader(String cardType, bool isValid, BuildContext context) {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
isValid ? Icons.check_circle : Icons.info,
|
isValid ? Icons.check_circle : Icons.info,
|
||||||
color: isValid ? Colors.green : Colors.orange,
|
color: isValid ? Colors.green : TColors.warning,
|
||||||
size: TSizes.iconMd,
|
size: TSizes.iconMd,
|
||||||
),
|
),
|
||||||
const SizedBox(width: TSizes.sm),
|
const SizedBox(width: TSizes.sm),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Extracted $cardType Information',
|
'Extracted $cardType Information',
|
||||||
style: const TextStyle(
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: TSizes.fontSizeMd,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -309,7 +319,7 @@ class IdCardVerificationStep extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInfoRow(String label, String value) {
|
Widget _buildInfoRow(String label, String value, BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: TSizes.sm),
|
padding: const EdgeInsets.only(bottom: TSizes.sm),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
@ -319,9 +329,10 @@ class IdCardVerificationStep extends StatelessWidget {
|
||||||
width: 100,
|
width: 100,
|
||||||
child: Text(
|
child: Text(
|
||||||
'$label:',
|
'$label:',
|
||||||
style: const TextStyle(
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: TColors.textSecondary,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -329,7 +340,7 @@ class IdCardVerificationStep extends StatelessWidget {
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
value,
|
value,
|
||||||
style: const TextStyle(color: TColors.textPrimary),
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -342,14 +353,14 @@ class IdCardVerificationStep extends StatelessWidget {
|
||||||
margin: const EdgeInsets.only(top: TSizes.sm),
|
margin: const EdgeInsets.only(top: TSizes.sm),
|
||||||
padding: const EdgeInsets.all(TSizes.sm),
|
padding: const EdgeInsets.all(TSizes.sm),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.orange.withOpacity(0.1),
|
color: TColors.warning.withOpacity(0.1),
|
||||||
borderRadius: BorderRadius.circular(TSizes.borderRadiusSm),
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusSm),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.warning_amber_rounded,
|
Icons.warning_amber_rounded,
|
||||||
color: Colors.orange,
|
color: TColors.warning,
|
||||||
size: TSizes.iconSm,
|
size: TSizes.iconSm,
|
||||||
),
|
),
|
||||||
const SizedBox(width: TSizes.sm),
|
const SizedBox(width: TSizes.sm),
|
||||||
|
@ -358,7 +369,7 @@ class IdCardVerificationStep extends StatelessWidget {
|
||||||
'Some information might be missing or incorrect. Please verify the extracted data.',
|
'Some information might be missing or incorrect. Please verify the extracted data.',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: TSizes.fontSizeXs,
|
fontSize: TSizes.fontSizeXs,
|
||||||
color: Colors.orange.shade800,
|
color: TColors.warning,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/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/identity_verification_controller.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/face_verification_section.dart';
|
|
||||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/id_info_form.dart';
|
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/id_info_form.dart';
|
||||||
import 'package:sigap/src/shared/widgets/form/form_section_header.dart';
|
import 'package:sigap/src/shared/widgets/form/form_section_header.dart';
|
||||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
@ -29,13 +28,12 @@ class IdentityVerificationStep extends StatelessWidget {
|
||||||
subtitle: 'Please provide additional personal details',
|
subtitle: 'Please provide additional personal details',
|
||||||
),
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
|
||||||
// Personal Information Form Section
|
// Personal Information Form Section
|
||||||
IdInfoForm(controller: controller, isOfficer: isOfficer),
|
IdInfoForm(controller: controller, isOfficer: isOfficer),
|
||||||
|
|
||||||
const SizedBox(height: TSizes.spaceBtwSections),
|
const SizedBox(height: TSizes.spaceBtwSections),
|
||||||
|
|
||||||
// Face Verification Section
|
|
||||||
FaceVerificationSection(controller: controller),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -4,6 +4,7 @@ import 'package:sigap/src/features/auth/presentasion/controllers/registration_fo
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/officer_info_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/officer_info_controller.dart';
|
||||||
import 'package:sigap/src/shared/widgets/form/form_section_header.dart';
|
import 'package:sigap/src/shared/widgets/form/form_section_header.dart';
|
||||||
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
import 'package:sigap/src/utils/validators/validation.dart';
|
import 'package:sigap/src/utils/validators/validation.dart';
|
||||||
|
|
||||||
class OfficerInfoStep extends StatelessWidget {
|
class OfficerInfoStep extends StatelessWidget {
|
||||||
|
@ -26,6 +27,8 @@ class OfficerInfoStep extends StatelessWidget {
|
||||||
subtitle: 'Please provide your officer details',
|
subtitle: 'Please provide your officer details',
|
||||||
),
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
|
||||||
// NRP field
|
// NRP field
|
||||||
Obx(
|
Obx(
|
||||||
() => CustomTextField(
|
() => CustomTextField(
|
||||||
|
|
|
@ -4,6 +4,7 @@ import 'package:sigap/src/features/auth/presentasion/controllers/registration_fo
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/personal_info_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/personal_info_controller.dart';
|
||||||
import 'package:sigap/src/shared/widgets/form/form_section_header.dart';
|
import 'package:sigap/src/shared/widgets/form/form_section_header.dart';
|
||||||
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
import 'package:sigap/src/utils/validators/validation.dart';
|
import 'package:sigap/src/utils/validators/validation.dart';
|
||||||
|
|
||||||
class PersonalInfoStep extends StatelessWidget {
|
class PersonalInfoStep extends StatelessWidget {
|
||||||
|
@ -26,6 +27,8 @@ class PersonalInfoStep extends StatelessWidget {
|
||||||
subtitle: 'Please provide your personal details',
|
subtitle: 'Please provide your personal details',
|
||||||
),
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
|
||||||
// First Name field
|
// First Name field
|
||||||
Obx(
|
Obx(
|
||||||
() => CustomTextField(
|
() => CustomTextField(
|
||||||
|
|
|
@ -24,14 +24,15 @@ class FormRegistrationScreen extends StatelessWidget {
|
||||||
|
|
||||||
// Set system overlay style
|
// Set system overlay style
|
||||||
SystemChrome.setSystemUIOverlayStyle(
|
SystemChrome.setSystemUIOverlayStyle(
|
||||||
const SystemUiOverlayStyle(
|
SystemUiOverlayStyle(
|
||||||
statusBarColor: Colors.transparent,
|
statusBarColor: Colors.transparent,
|
||||||
statusBarIconBrightness: Brightness.dark,
|
statusBarIconBrightness: dark ? Brightness.light : Brightness.dark,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: dark ? TColors.dark : TColors.light,
|
backgroundColor:
|
||||||
|
dark ? Theme.of(context).scaffoldBackgroundColor : TColors.light,
|
||||||
appBar: _buildAppBar(context, dark),
|
appBar: _buildAppBar(context, dark),
|
||||||
body: Obx(() {
|
body: Obx(() {
|
||||||
// Show loading state while controller initializes
|
// Show loading state while controller initializes
|
||||||
|
@ -85,9 +86,9 @@ class FormRegistrationScreen extends StatelessWidget {
|
||||||
'Complete Your Profile',
|
'Complete Your Profile',
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
centerTitle: true,
|
centerTitle: false,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.arrow_back,
|
Icons.arrow_back,
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:sigap/src/cores/services/facial_verification_service.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/selfie_verification_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/image_upload/image_uploader.dart';
|
||||||
|
@ -18,6 +19,7 @@ class SelfieVerificationStep extends StatelessWidget {
|
||||||
final formKey = GlobalKey<FormState>();
|
final formKey = GlobalKey<FormState>();
|
||||||
final controller = Get.find<SelfieVerificationController>();
|
final controller = Get.find<SelfieVerificationController>();
|
||||||
final mainController = Get.find<FormRegistrationController>();
|
final mainController = Get.find<FormRegistrationController>();
|
||||||
|
final facialVerificationService = FacialVerificationService.instance;
|
||||||
mainController.formKey = formKey;
|
mainController.formKey = formKey;
|
||||||
|
|
||||||
return Form(
|
return Form(
|
||||||
|
@ -27,6 +29,32 @@ class SelfieVerificationStep extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
_buildHeader(context),
|
_buildHeader(context),
|
||||||
|
|
||||||
|
// Development mode indicator
|
||||||
|
if (facialVerificationService.skipFaceVerification)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: TSizes.spaceBtwItems),
|
||||||
|
padding: const EdgeInsets.all(TSizes.sm),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.amber.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusSm),
|
||||||
|
border: Border.all(color: Colors.amber),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.code, color: Colors.amber, size: TSizes.iconSm),
|
||||||
|
const SizedBox(width: TSizes.xs),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Development mode: Face verification is skipped',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelSmall?.copyWith(color: Colors.amber),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Selfie Upload Widget
|
// Selfie Upload Widget
|
||||||
Obx(
|
Obx(
|
||||||
() => ImageUploader(
|
() => ImageUploader(
|
||||||
|
@ -85,66 +113,88 @@ class SelfieVerificationStep extends StatelessWidget {
|
||||||
: const SizedBox.shrink(),
|
: const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Face match with ID card indicator
|
// Face match with ID card indicator - Updated with TipsContainer style
|
||||||
Obx(() {
|
Obx(() {
|
||||||
if (controller.selfieImage.value != null &&
|
if (controller.selfieImage.value != null &&
|
||||||
controller.isSelfieValid.value) {
|
controller.isSelfieValid.value) {
|
||||||
return Padding(
|
final isMatch = controller.isMatchWithIDCard.value;
|
||||||
padding: const EdgeInsets.symmetric(vertical: TSizes.sm),
|
final isComparing = controller.isComparingWithIDCard.value;
|
||||||
child: Card(
|
|
||||||
color:
|
// Define colors based on match status
|
||||||
controller.isMatchWithIDCard.value
|
final Color baseColor = isMatch ? Colors.green : TColors.warning;
|
||||||
? Colors.green.shade50
|
final IconData statusIcon =
|
||||||
: Colors.orange.shade50,
|
isMatch ? Icons.check_circle : Icons.face;
|
||||||
child: Padding(
|
|
||||||
|
// Message based on status
|
||||||
|
final String message =
|
||||||
|
isMatch
|
||||||
|
? 'Your selfie matches with your ID card photo (${(controller.matchConfidence.value * 100).toStringAsFixed(1)}% confidence)'
|
||||||
|
: isComparing
|
||||||
|
? 'Comparing your selfie with your ID card photo...'
|
||||||
|
: 'Your selfie doesn\'t match with your ID card photo.';
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: TSizes.sm),
|
||||||
padding: const EdgeInsets.all(TSizes.md),
|
padding: const EdgeInsets.all(TSizes.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: baseColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusSm),
|
||||||
|
border: Border.all(color: baseColor.withOpacity(0.3)),
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(statusIcon, color: baseColor, size: TSizes.iconMd),
|
||||||
controller.isMatchWithIDCard.value
|
|
||||||
? Icons.check_circle
|
|
||||||
: Icons.face,
|
|
||||||
color:
|
|
||||||
controller.isMatchWithIDCard.value
|
|
||||||
? Colors.green
|
|
||||||
: Colors.orange,
|
|
||||||
),
|
|
||||||
const SizedBox(width: TSizes.sm),
|
const SizedBox(width: TSizes.sm),
|
||||||
Expanded(
|
Text(
|
||||||
child: Text(
|
|
||||||
'Face ID Match',
|
'Face ID Match',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color:
|
color: baseColor,
|
||||||
controller.isMatchWithIDCard.value
|
|
||||||
? Colors.green
|
|
||||||
: Colors.orange,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: TSizes.sm),
|
const SizedBox(height: TSizes.sm),
|
||||||
Text(
|
Text(
|
||||||
controller.isMatchWithIDCard.value
|
message,
|
||||||
? 'Your selfie matches with your ID card photo (${(controller.matchConfidence.value * 100).toStringAsFixed(1)}% confidence)'
|
style: TextStyle(
|
||||||
: controller.isComparingWithIDCard.value
|
fontSize: TSizes.fontSizeSm,
|
||||||
? 'Comparing your selfie with your ID card photo...'
|
color: baseColor.withOpacity(0.8),
|
||||||
: 'Your selfie doesn\'t match with your ID card photo.',
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Show retry button if needed
|
||||||
|
if (!isComparing && !isMatch) ...[
|
||||||
const SizedBox(height: TSizes.sm),
|
const SizedBox(height: TSizes.sm),
|
||||||
if (!controller.isComparingWithIDCard.value &&
|
TextButton.icon(
|
||||||
!controller.isMatchWithIDCard.value)
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: controller.verifyFaceMatchWithIDCard,
|
onPressed: controller.verifyFaceMatchWithIDCard,
|
||||||
child: const Text('Try Face Matching Again'),
|
icon: Icon(
|
||||||
|
Icons.refresh,
|
||||||
|
color: baseColor,
|
||||||
|
size: TSizes.iconSm,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
'Try Face Matching Again',
|
||||||
|
style: TextStyle(color: baseColor),
|
||||||
|
),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: TSizes.md,
|
||||||
|
vertical: TSizes.xs,
|
||||||
|
),
|
||||||
|
backgroundColor: baseColor.withOpacity(0.1),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
TSizes.borderRadiusSm,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
],
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -159,13 +209,13 @@ class SelfieVerificationStep extends StatelessWidget {
|
||||||
padding: const EdgeInsets.only(top: TSizes.sm),
|
padding: const EdgeInsets.only(top: TSizes.sm),
|
||||||
child: Text(
|
child: Text(
|
||||||
controller.selfieError.value,
|
controller.selfieError.value,
|
||||||
style: const TextStyle(color: Colors.red),
|
style: TextStyle(color: TColors.error),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: const SizedBox.shrink(),
|
: const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: TSizes.spaceBtwSections),
|
const SizedBox(height: TSizes.spaceBtwSections / 2),
|
||||||
|
|
||||||
// Tips for taking a good selfie
|
// Tips for taking a good selfie
|
||||||
_buildSelfieTips(),
|
_buildSelfieTips(),
|
||||||
|
@ -180,27 +230,24 @@ class SelfieVerificationStep extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Selfie Verification',
|
'Selfie Verification',
|
||||||
style: TextStyle(
|
style: Theme.of(
|
||||||
fontSize: TSizes.fontSizeLg,
|
context,
|
||||||
|
).textTheme.headlineSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: TColors.textPrimary,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: TSizes.sm),
|
const SizedBox(height: TSizes.sm),
|
||||||
Text(
|
Text(
|
||||||
'Take a clear selfie for identity verification',
|
'Take a clear selfie for identity verification',
|
||||||
style: TextStyle(
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
fontSize: TSizes.fontSizeSm,
|
|
||||||
color: TColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: TSizes.xs),
|
const SizedBox(height: TSizes.xs),
|
||||||
Text(
|
Text(
|
||||||
'Make sure your face is well-lit and clearly visible',
|
'Make sure your face is well-lit and clearly visible',
|
||||||
style: TextStyle(
|
style: Theme.of(
|
||||||
fontSize: TSizes.fontSizeXs,
|
context,
|
||||||
|
).textTheme.bodySmall?.copyWith(
|
||||||
fontStyle: FontStyle.italic,
|
fontStyle: FontStyle.italic,
|
||||||
color: TColors.textSecondary,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: TSizes.spaceBtwItems),
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
@ -218,10 +265,10 @@ class SelfieVerificationStep extends StatelessWidget {
|
||||||
'Ensure your entire face is visible',
|
'Ensure your entire face is visible',
|
||||||
'Remove glasses and face coverings',
|
'Remove glasses and face coverings',
|
||||||
],
|
],
|
||||||
backgroundColor: TColors.primary,
|
backgroundColor: TColors.primary.withOpacity(0.1),
|
||||||
textColor: TColors.primary,
|
textColor: TColors.primary,
|
||||||
iconColor: TColors.primary,
|
iconColor: TColors.primary,
|
||||||
borderColor: TColors.primary,
|
borderColor: TColors.primary.withOpacity(0.3),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ import 'package:sigap/src/features/auth/presentasion/controllers/steps/unit_info
|
||||||
import 'package:sigap/src/shared/widgets/dropdown/custom_dropdown.dart';
|
import 'package:sigap/src/shared/widgets/dropdown/custom_dropdown.dart';
|
||||||
import 'package:sigap/src/shared/widgets/form/form_section_header.dart';
|
import 'package:sigap/src/shared/widgets/form/form_section_header.dart';
|
||||||
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
import 'package:sigap/src/utils/validators/validation.dart';
|
import 'package:sigap/src/utils/validators/validation.dart';
|
||||||
|
|
||||||
class UnitInfoStep extends StatelessWidget {
|
class UnitInfoStep extends StatelessWidget {
|
||||||
|
@ -27,6 +28,8 @@ class UnitInfoStep extends StatelessWidget {
|
||||||
subtitle: 'Please provide your unit details',
|
subtitle: 'Please provide your unit details',
|
||||||
),
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
|
||||||
// Position field
|
// Position field
|
||||||
Obx(
|
Obx(
|
||||||
() => CustomTextField(
|
() => CustomTextField(
|
||||||
|
|
|
@ -1,67 +1,331 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/features/auth/data/dummy/indonesian_cities.dart';
|
import 'package:sigap/src/features/auth/data/models/administrative_division.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/location_selection_controller.dart';
|
||||||
import 'package:sigap/src/utils/constants/colors.dart';
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
class CitySelectionPage extends StatefulWidget {
|
class LocationSelectionPage extends StatefulWidget {
|
||||||
const CitySelectionPage({super.key});
|
final DivisionType initialDivisionType;
|
||||||
|
final String? parentId;
|
||||||
|
final String? searchQuery;
|
||||||
|
final List<String> breadcrumbs;
|
||||||
|
|
||||||
|
const LocationSelectionPage({
|
||||||
|
super.key,
|
||||||
|
this.initialDivisionType = DivisionType.province,
|
||||||
|
this.parentId,
|
||||||
|
this.searchQuery,
|
||||||
|
this.breadcrumbs = const [],
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<CitySelectionPage> createState() => _CitySelectionPageState();
|
State<LocationSelectionPage> createState() => _LocationSelectionPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CitySelectionPageState extends State<CitySelectionPage> {
|
class _LocationSelectionPageState extends State<LocationSelectionPage> {
|
||||||
final TextEditingController _searchController = TextEditingController();
|
late final LocationSelectionController controller;
|
||||||
final RxList<String> _filteredCities = <String>[].obs;
|
bool manualInput = false;
|
||||||
final List<String> _allCities = indonesianCities;
|
final TextEditingController manualController = TextEditingController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_filteredCities.value = _allCities;
|
controller = Get.put(LocationSelectionController());
|
||||||
|
// Set manual input to true by default
|
||||||
|
manualInput = true;
|
||||||
|
|
||||||
_searchController.addListener(() {
|
// Ensure controller is properly initialized before accessing reactive properties
|
||||||
_filterCities(_searchController.text);
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
controller.loadDivisions(widget.initialDivisionType, widget.parentId);
|
||||||
|
if (widget.searchQuery != null && widget.searchQuery!.isNotEmpty) {
|
||||||
|
controller.searchController.text = widget.searchQuery!;
|
||||||
|
controller.updateSearch(widget.searchQuery!);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_searchController.dispose();
|
manualController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _filterCities(String query) {
|
|
||||||
if (query.isEmpty) {
|
|
||||||
_filteredCities.value = _allCities;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final lowercaseQuery = query.toLowerCase();
|
|
||||||
_filteredCities.value =
|
|
||||||
_allCities
|
|
||||||
.where((city) => city.toLowerCase().contains(lowercaseQuery))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: _buildAppBar(context, isDark, controller),
|
||||||
title: const Text('Select Place of Birth'),
|
body:
|
||||||
backgroundColor: TColors.primary,
|
manualInput
|
||||||
foregroundColor: Colors.white,
|
? _buildManualInputSection()
|
||||||
),
|
: _buildLocationSelectionSection(),
|
||||||
body: Column(
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modified to show manual input section with a more prominent UI
|
||||||
|
Widget _buildManualInputSection() {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(TSizes.defaultSpace),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// Title and toggle
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Enter Place of Birth',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// TextButton.icon(
|
||||||
|
// onPressed: () {
|
||||||
|
// setState(() {
|
||||||
|
// manualInput = !manualInput;
|
||||||
|
// });
|
||||||
|
// },
|
||||||
|
// icon: const Icon(Icons.list),
|
||||||
|
// label: const Text('Select from list'),
|
||||||
|
// ),
|
||||||
|
// ],
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.md),
|
||||||
|
// Instruction text
|
||||||
|
Text(
|
||||||
|
'Type the name of your place of birth below:',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.md),
|
||||||
|
// Input field
|
||||||
|
TextField(
|
||||||
|
controller: manualController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Place of Birth',
|
||||||
|
hintText: 'e.g., Jakarta, Bandung, Surabaya, etc.',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||||
|
),
|
||||||
|
textCapitalization: TextCapitalization.words,
|
||||||
|
autofocus: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.lg),
|
||||||
|
// Confirm button
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (manualController.text.trim().isNotEmpty) {
|
||||||
|
Get.back(result: manualController.text.trim());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: TSizes.md),
|
||||||
|
),
|
||||||
|
child: const Text('Confirm'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Helper text
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
'Please make sure you enter the correct place name',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLocationSelectionSection() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// Toggle between select and manual input
|
||||||
Padding(
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
left: TSizes.defaultSpace,
|
||||||
|
right: TSizes.defaultSpace,
|
||||||
|
top: TSizes.defaultSpace,
|
||||||
|
bottom: 0,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Select Place of Birth',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
manualInput = !manualInput;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: const Text('Type manually'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (widget.breadcrumbs.isNotEmpty)
|
||||||
|
_buildBreadcrumbs(context, controller),
|
||||||
|
_buildSearchField(context, controller),
|
||||||
|
Expanded(child: _buildListSection()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AppBar _buildAppBar(
|
||||||
|
BuildContext context,
|
||||||
|
bool isDark,
|
||||||
|
LocationSelectionController controller,
|
||||||
|
) {
|
||||||
|
String title;
|
||||||
|
switch (widget.initialDivisionType) {
|
||||||
|
case DivisionType.province:
|
||||||
|
title = 'Select Province';
|
||||||
|
break;
|
||||||
|
case DivisionType.regency:
|
||||||
|
title = 'Select Regency/City';
|
||||||
|
break;
|
||||||
|
case DivisionType.district:
|
||||||
|
title = 'Select District';
|
||||||
|
break;
|
||||||
|
case DivisionType.village:
|
||||||
|
title = 'Select Village';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AppBar(
|
||||||
|
title: Text(title),
|
||||||
|
centerTitle: false,
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
iconTheme: IconThemeData(color: isDark ? TColors.white : TColors.black),
|
||||||
|
actions: [
|
||||||
|
Obx(
|
||||||
|
() =>
|
||||||
|
controller.searchActive.value
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: controller.clearSearch,
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBreadcrumbs(
|
||||||
|
BuildContext context,
|
||||||
|
LocationSelectionController controller,
|
||||||
|
) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: TSizes.defaultSpace),
|
||||||
|
height: 40,
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: widget.breadcrumbs.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final bool isLastItem = index == widget.breadcrumbs.length - 1;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
// Breadcrumb item
|
||||||
|
GestureDetector(
|
||||||
|
onTap:
|
||||||
|
isLastItem
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
// Navigate back to this level
|
||||||
|
controller.navigateToBreadcrumb(
|
||||||
|
index,
|
||||||
|
widget.breadcrumbs,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
isLastItem
|
||||||
|
? Theme.of(context).primaryColor.withOpacity(0.2)
|
||||||
|
: Theme.of(context).cardColor,
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusSm),
|
||||||
|
border: Border.all(
|
||||||
|
color:
|
||||||
|
isLastItem
|
||||||
|
? Theme.of(context).primaryColor
|
||||||
|
: Theme.of(context).dividerColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
widget.breadcrumbs[index],
|
||||||
|
style: TextStyle(
|
||||||
|
color:
|
||||||
|
isLastItem
|
||||||
|
? Theme.of(context).primaryColor
|
||||||
|
: Theme.of(context).textTheme.bodyMedium?.color,
|
||||||
|
fontWeight:
|
||||||
|
isLastItem ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Arrow separator
|
||||||
|
if (!isLastItem)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
child: Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).dividerColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSearchField(
|
||||||
|
BuildContext context,
|
||||||
|
LocationSelectionController controller,
|
||||||
|
) {
|
||||||
|
return Padding(
|
||||||
padding: const EdgeInsets.all(TSizes.defaultSpace),
|
padding: const EdgeInsets.all(TSizes.defaultSpace),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _searchController,
|
controller: controller.searchController,
|
||||||
|
onChanged: (value) => controller.updateSearch(value),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Search city or regency',
|
hintText: 'Search location...',
|
||||||
prefixIcon: const Icon(Icons.search),
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
suffixIcon: Obx(
|
||||||
|
() =>
|
||||||
|
controller.searchController.text.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: controller.clearSearch,
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
|
||||||
),
|
),
|
||||||
|
@ -70,56 +334,185 @@ class _CitySelectionPageState extends State<CitySelectionPage> {
|
||||||
horizontal: TSizes.md,
|
horizontal: TSizes.md,
|
||||||
),
|
),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.grey.withOpacity(0.1),
|
fillColor: Theme.of(context).cardColor,
|
||||||
),
|
),
|
||||||
textInputAction: TextInputAction.search,
|
textInputAction: TextInputAction.search,
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
Expanded(
|
}
|
||||||
child: Obx(
|
|
||||||
() =>
|
Widget _buildEmptyState(BuildContext context) {
|
||||||
_filteredCities.isEmpty
|
return Center(
|
||||||
? Center(
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.location_city,
|
Icons.location_city,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: Colors.grey.withOpacity(0.5),
|
color: Theme.of(context).disabledColor,
|
||||||
),
|
),
|
||||||
const SizedBox(height: TSizes.sm),
|
const SizedBox(height: TSizes.sm),
|
||||||
Text(
|
Text(
|
||||||
'No cities found',
|
'No locations found',
|
||||||
style: TextStyle(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: Colors.grey.withOpacity(0.8),
|
color: Theme.of(context).disabledColor,
|
||||||
fontSize: TSizes.fontSizeMd,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
: ListView.builder(
|
}
|
||||||
itemCount: _filteredCities.length,
|
|
||||||
|
// Modified list section to avoid the GetX reactive issues
|
||||||
|
Widget _buildListSection() {
|
||||||
|
// Use a local snapshot of values to avoid reactive dependencies
|
||||||
|
final isLoading = controller.isLoading.value;
|
||||||
|
final divisions = List<AdministrativeDivision>.from(
|
||||||
|
controller.filteredDivisions,
|
||||||
|
);
|
||||||
|
final isEmpty = divisions.isEmpty;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
return _buildEmptyState(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: divisions.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final city = _filteredCities[index];
|
final division = divisions[index];
|
||||||
|
// Take snapshot of reactive values before using them
|
||||||
|
final isSearchActive = controller.searchActive.value;
|
||||||
|
final searchQuery = controller.searchController.text;
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(city),
|
title:
|
||||||
onTap: () {
|
isSearchActive && searchQuery.isNotEmpty
|
||||||
Get.back(result: city);
|
? RichText(
|
||||||
},
|
text: _highlightSearchText(
|
||||||
trailing: const Icon(Icons.chevron_right),
|
division.name,
|
||||||
|
searchQuery,
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(division.name),
|
||||||
|
subtitle:
|
||||||
|
isSearchActive && division.type != DivisionType.village
|
||||||
|
? Text(_getSubtitleText(division))
|
||||||
|
: null,
|
||||||
|
trailing:
|
||||||
|
division.type != DivisionType.village
|
||||||
|
? const Icon(Icons.chevron_right)
|
||||||
|
: null,
|
||||||
|
onTap: () => _handleDivisionTap(division, controller, context),
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: TSizes.defaultSpace,
|
horizontal: TSizes.defaultSpace,
|
||||||
vertical: TSizes.xs,
|
vertical: TSizes.xs,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TextSpan _highlightSearchText(
|
||||||
|
String text,
|
||||||
|
String query,
|
||||||
|
BuildContext context,
|
||||||
|
) {
|
||||||
|
final lowercaseText = text.toLowerCase();
|
||||||
|
final lowercaseQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
if (lowercaseQuery.isEmpty || !lowercaseText.contains(lowercaseQuery)) {
|
||||||
|
return TextSpan(text: text);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int startIndex = lowercaseText.indexOf(lowercaseQuery);
|
||||||
|
final int endIndex = startIndex + lowercaseQuery.length;
|
||||||
|
|
||||||
|
return TextSpan(
|
||||||
|
children: [
|
||||||
|
TextSpan(text: text.substring(0, startIndex)),
|
||||||
|
TextSpan(
|
||||||
|
text: text.substring(startIndex, endIndex),
|
||||||
|
style: TextStyle(
|
||||||
|
backgroundColor: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.secondary.withOpacity(0.2),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Theme.of(context).colorScheme.secondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextSpan(text: text.substring(endIndex)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getSubtitleText(AdministrativeDivision division) {
|
||||||
|
switch (division.type) {
|
||||||
|
case DivisionType.province:
|
||||||
|
return 'Province';
|
||||||
|
case DivisionType.regency:
|
||||||
|
return 'Regency/City';
|
||||||
|
case DivisionType.district:
|
||||||
|
return 'District';
|
||||||
|
case DivisionType.village:
|
||||||
|
return 'Village';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDivisionTap(
|
||||||
|
AdministrativeDivision division,
|
||||||
|
LocationSelectionController controller,
|
||||||
|
BuildContext context,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
// Move the reactive check to a local variable before use
|
||||||
|
final isSearchActive = controller.searchActive.value;
|
||||||
|
|
||||||
|
// If it's a search result, return the full breadcrumb path
|
||||||
|
if (isSearchActive) {
|
||||||
|
final fullLocation = await controller.getFullLocationString(division);
|
||||||
|
Get.back(result: fullLocation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a village (lowest level), return the selection
|
||||||
|
if (division.type == DivisionType.village) {
|
||||||
|
final fullLocation = await controller.getFullLocationString(division);
|
||||||
|
Get.back(result: fullLocation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise navigate to the next level
|
||||||
|
final nextDivisionType = _getNextDivisionType(division.type);
|
||||||
|
final updatedBreadcrumbs = [...widget.breadcrumbs, division.name];
|
||||||
|
|
||||||
|
Get.to(
|
||||||
|
() => LocationSelectionPage(
|
||||||
|
initialDivisionType: nextDivisionType,
|
||||||
|
parentId: division.id,
|
||||||
|
breadcrumbs: updatedBreadcrumbs,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// Handle any errors that might occur during navigation
|
||||||
|
print("Error handling division tap: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DivisionType _getNextDivisionType(DivisionType currentType) {
|
||||||
|
switch (currentType) {
|
||||||
|
case DivisionType.province:
|
||||||
|
return DivisionType.regency;
|
||||||
|
case DivisionType.regency:
|
||||||
|
return DivisionType.district;
|
||||||
|
case DivisionType.district:
|
||||||
|
return DivisionType.village;
|
||||||
|
default:
|
||||||
|
return DivisionType.village; // Should not reach here
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
import 'package:sigap/src/cores/services/facial_verification_service.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart';
|
||||||
import 'package:sigap/src/shared/widgets/form/form_section_header.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/form/verification_status.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/shared/widgets/verification/validation_message_card.dart';
|
||||||
import 'package:sigap/src/utils/constants/colors.dart';
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
@ -14,6 +16,8 @@ class FaceVerificationSection extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
@ -31,7 +35,7 @@ class FaceVerificationSection extends StatelessWidget {
|
||||||
),
|
),
|
||||||
|
|
||||||
// Face match verification button
|
// Face match verification button
|
||||||
_buildFaceVerificationButton(),
|
_buildFaceVerificationButton(context),
|
||||||
|
|
||||||
// Face Verification Message
|
// Face Verification Message
|
||||||
Obx(
|
Obx(
|
||||||
|
@ -49,11 +53,38 @@ class FaceVerificationSection extends StatelessWidget {
|
||||||
)
|
)
|
||||||
: const SizedBox.shrink(),
|
: const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Development mode indicator
|
||||||
|
if (FacialVerificationService.instance.skipFaceVerification)
|
||||||
|
_buildDevelopmentModeIndicator(context),
|
||||||
|
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
|
||||||
|
// Tips for face verification using TipsContainer
|
||||||
|
_buildTipsContainer(context),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFaceVerificationButton() {
|
Widget _buildTipsContainer(BuildContext context) {
|
||||||
|
return TipsContainer(
|
||||||
|
title: 'Face Verification Tips:',
|
||||||
|
tips: [
|
||||||
|
'Make sure your face is clearly visible in both ID and selfie',
|
||||||
|
'Good lighting is essential for accurate matching',
|
||||||
|
'Keep a neutral expression, similar to your ID photo',
|
||||||
|
'Remove glasses and face coverings',
|
||||||
|
'Face the camera directly without tilting your head',
|
||||||
|
],
|
||||||
|
backgroundColor: TColors.primary.withOpacity(0.1),
|
||||||
|
textColor: TColors.primary,
|
||||||
|
iconColor: TColors.primary,
|
||||||
|
leadingIcon: Icons.face,
|
||||||
|
borderColor: TColors.primary.withOpacity(0.3),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFaceVerificationButton(BuildContext context) {
|
||||||
return Obx(
|
return Obx(
|
||||||
() => ElevatedButton.icon(
|
() => ElevatedButton.icon(
|
||||||
onPressed:
|
onPressed:
|
||||||
|
@ -68,15 +99,47 @@ class FaceVerificationSection extends StatelessWidget {
|
||||||
),
|
),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
controller.isFaceVerified.value ? Colors.green : TColors.primary,
|
controller.isFaceVerified.value
|
||||||
|
? TColors.success
|
||||||
|
: Theme.of(context).primaryColor,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
minimumSize: const Size(double.infinity, 50),
|
minimumSize: const Size(double.infinity, TSizes.buttonHeight),
|
||||||
disabledBackgroundColor:
|
disabledBackgroundColor:
|
||||||
controller.isFaceVerified.value
|
controller.isFaceVerified.value
|
||||||
? Colors.green.withOpacity(0.7)
|
? TColors.success.withOpacity(0.7)
|
||||||
: null,
|
: null,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.buttonRadius),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildDevelopmentModeIndicator(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(top: TSizes.spaceBtwItems),
|
||||||
|
padding: const EdgeInsets.all(TSizes.sm),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.amber.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusSm),
|
||||||
|
border: Border.all(color: Colors.amber),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.code, color: Colors.amber.shade800, size: TSizes.iconSm),
|
||||||
|
const SizedBox(width: TSizes.xs),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Development mode: Face verification is skipped',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall?.copyWith(color: Colors.amber.shade900),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,15 @@ class IdInfoForm extends StatelessWidget {
|
||||||
required this.isOfficer,
|
required this.isOfficer,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
// Ensure data is pre-filled if available
|
||||||
|
if (controller.extractedIdCardNumber?.isNotEmpty == true ||
|
||||||
|
controller.extractedName?.isNotEmpty == true) {
|
||||||
|
controller.prefillExtractedData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
|
@ -28,6 +37,9 @@ class IdInfoForm extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
// Different fields based on role
|
// Different fields based on role
|
||||||
if (!isOfficer) ...[
|
if (!isOfficer) ...[
|
||||||
|
// ID Confirmation banner if we have extracted data
|
||||||
|
_buildExtractedDataConfirmation(context),
|
||||||
|
|
||||||
// NIK field for non-officers
|
// NIK field for non-officers
|
||||||
_buildNikField(),
|
_buildNikField(),
|
||||||
|
|
||||||
|
@ -50,10 +62,11 @@ class IdInfoForm extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Gender selection
|
// Gender selection - Fix for null check issue
|
||||||
Obx(
|
Obx(
|
||||||
() => GenderSelection(
|
() => GenderSelection(
|
||||||
selectedGender: controller.selectedGender.value!,
|
// Ensure we never pass null to GenderSelection
|
||||||
|
selectedGender: controller.selectedGender.value,
|
||||||
onGenderChanged: (value) {
|
onGenderChanged: (value) {
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
controller.selectedGender.value = value;
|
controller.selectedGender.value = value;
|
||||||
|
@ -86,6 +99,97 @@ class IdInfoForm extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extracted ID data confirmation banner
|
||||||
|
Widget _buildExtractedDataConfirmation(BuildContext context) {
|
||||||
|
final extractedIdCardNumber = controller.extractedIdCardNumber;
|
||||||
|
final extractedName = controller.extractedName;
|
||||||
|
|
||||||
|
if ((extractedIdCardNumber?.isNotEmpty == true) ||
|
||||||
|
(extractedName?.isNotEmpty == true)) {
|
||||||
|
return Obx(() {
|
||||||
|
final isPreFilledNik = controller.isPreFilledNik.value;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: TSizes.spaceBtwItems),
|
||||||
|
padding: const EdgeInsets.all(TSizes.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.green.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusSm),
|
||||||
|
border: Border.all(color: Colors.green.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: Colors.green,
|
||||||
|
size: TSizes.iconSm,
|
||||||
|
),
|
||||||
|
const SizedBox(width: TSizes.xs),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Data extracted from ID Card',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.green,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.sm),
|
||||||
|
if (extractedIdCardNumber?.isNotEmpty == true) ...[
|
||||||
|
Text(
|
||||||
|
'NIK: $extractedIdCardNumber',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.xs),
|
||||||
|
],
|
||||||
|
if (extractedName?.isNotEmpty == true) ...[
|
||||||
|
Text(
|
||||||
|
'Name: $extractedName',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.xs),
|
||||||
|
],
|
||||||
|
if (!isPreFilledNik &&
|
||||||
|
extractedIdCardNumber?.isNotEmpty == true) ...[
|
||||||
|
const SizedBox(height: TSizes.xs),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
controller.prefillExtractedData();
|
||||||
|
},
|
||||||
|
icon: Icon(
|
||||||
|
Icons.auto_awesome,
|
||||||
|
size: TSizes.iconSm,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
'Use extracted data',
|
||||||
|
style: TextStyle(color: Theme.of(context).primaryColor),
|
||||||
|
),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: TSizes.sm,
|
||||||
|
vertical: TSizes.xs / 2,
|
||||||
|
),
|
||||||
|
backgroundColor: Theme.of(
|
||||||
|
context,
|
||||||
|
).primaryColor.withOpacity(0.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
// NIK field
|
// NIK field
|
||||||
Widget _buildNikField() {
|
Widget _buildNikField() {
|
||||||
return Obx(
|
return Obx(
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
import 'package:sigap/src/features/auth/data/models/administrative_division.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_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/features/auth/presentasion/pages/registration-form/widgets/city_selection.dart';
|
||||||
import 'package:sigap/src/utils/constants/colors.dart';
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
|
@ -12,6 +13,8 @@ class PlaceOfBirthField extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
return Obx(
|
return Obx(
|
||||||
() => Column(
|
() => Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
@ -19,7 +22,7 @@ class PlaceOfBirthField extends StatelessWidget {
|
||||||
Text('Place of Birth', style: Theme.of(context).textTheme.bodyMedium),
|
Text('Place of Birth', style: Theme.of(context).textTheme.bodyMedium),
|
||||||
const SizedBox(height: TSizes.xs),
|
const SizedBox(height: TSizes.xs),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => _navigateToCitySelection(context),
|
onTap: () => _navigateToLocationSelection(context),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: TSizes.md,
|
horizontal: TSizes.md,
|
||||||
|
@ -30,7 +33,9 @@ class PlaceOfBirthField extends StatelessWidget {
|
||||||
color:
|
color:
|
||||||
controller.placeOfBirthError.value.isNotEmpty
|
controller.placeOfBirthError.value.isNotEmpty
|
||||||
? TColors.error
|
? TColors.error
|
||||||
: TColors.textSecondary,
|
: isDark
|
||||||
|
? TColors.darkGrey
|
||||||
|
: TColors.grey,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(TSizes.borderRadiusLg),
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusLg),
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
|
@ -38,21 +43,25 @@ class PlaceOfBirthField extends StatelessWidget {
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Expanded(
|
||||||
|
child: Text(
|
||||||
controller.placeOfBirthController.text.isEmpty
|
controller.placeOfBirthController.text.isEmpty
|
||||||
? 'Select Place of Birth'
|
? 'Select Place of Birth'
|
||||||
: controller.placeOfBirthController.text,
|
: controller.placeOfBirthController.text,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color:
|
color:
|
||||||
controller.placeOfBirthController.text.isEmpty
|
controller.placeOfBirthController.text.isEmpty
|
||||||
? Theme.of(context).textTheme.bodyMedium?.color
|
? Theme.of(context).hintColor
|
||||||
: TColors.textSecondary,
|
: Theme.of(context).textTheme.bodyMedium?.color,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Icon(
|
Icon(
|
||||||
Icons.location_city,
|
Icons.place,
|
||||||
size: TSizes.iconSm,
|
size: TSizes.iconSm,
|
||||||
color: Theme.of(context).textTheme.bodyMedium?.color,
|
color: isDark ? TColors.white : TColors.dark,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
@ -63,7 +72,7 @@ class PlaceOfBirthField extends StatelessWidget {
|
||||||
padding: const EdgeInsets.only(top: TSizes.xs, left: TSizes.xs),
|
padding: const EdgeInsets.only(top: TSizes.xs, left: TSizes.xs),
|
||||||
child: Text(
|
child: Text(
|
||||||
controller.placeOfBirthError.value,
|
controller.placeOfBirthError.value,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: TextStyle(
|
||||||
color: TColors.error,
|
color: TColors.error,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
|
@ -75,10 +84,15 @@ class PlaceOfBirthField extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToCitySelection(BuildContext context) async {
|
void _navigateToLocationSelection(BuildContext context) async {
|
||||||
final selectedCity = await Get.to<String>(() => const CitySelectionPage());
|
final result = await Get.to<String>(
|
||||||
if (selectedCity != null && selectedCity.isNotEmpty) {
|
() => LocationSelectionPage(
|
||||||
controller.placeOfBirthController.text = selectedCity;
|
initialDivisionType: DivisionType.province,
|
||||||
|
breadcrumbs: [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (result != null && result.isNotEmpty) {
|
||||||
|
controller.placeOfBirthController.text = result;
|
||||||
controller.placeOfBirthError.value = '';
|
controller.placeOfBirthError.value = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart';
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart';
|
||||||
import 'package:sigap/src/utils/constants/colors.dart';
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
class VerificationActionButton extends StatelessWidget {
|
class VerificationActionButton extends StatelessWidget {
|
||||||
final IdentityVerificationController controller;
|
final IdentityVerificationController controller;
|
||||||
|
@ -23,9 +23,13 @@ class VerificationActionButton extends StatelessWidget {
|
||||||
: 'Verify Personal Information',
|
: 'Verify Personal Information',
|
||||||
),
|
),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: TColors.primary,
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
minimumSize: const Size(double.infinity, 50),
|
minimumSize: const Size(double.infinity, TSizes.buttonHeight),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.buttonRadius),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,362 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:sigap/src/features/auth/data/models/administrative_division.dart';
|
||||||
|
|
||||||
|
/// Service responsible for loading administrative division data from JSON files
|
||||||
|
class AdministrativeRepository {
|
||||||
|
// Singleton pattern
|
||||||
|
static final AdministrativeRepository _instance = AdministrativeRepository._internal();
|
||||||
|
factory AdministrativeRepository() => _instance;
|
||||||
|
AdministrativeRepository._internal();
|
||||||
|
|
||||||
|
// Cache for data
|
||||||
|
List<AdministrativeDivision>? _provinces;
|
||||||
|
List<AdministrativeDivision>? _regencies;
|
||||||
|
List<AdministrativeDivision>? _districts;
|
||||||
|
List<AdministrativeDivision>? _villages;
|
||||||
|
|
||||||
|
// Getters for cached data
|
||||||
|
Future<List<AdministrativeDivision>> get provinces async {
|
||||||
|
if (_provinces == null) {
|
||||||
|
await _loadProvinces();
|
||||||
|
}
|
||||||
|
return _provinces!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<AdministrativeDivision>> get regencies async {
|
||||||
|
if (_regencies == null) {
|
||||||
|
await _loadRegencies();
|
||||||
|
}
|
||||||
|
return _regencies!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<AdministrativeDivision>> get districts async {
|
||||||
|
if (_districts == null) {
|
||||||
|
await _loadDistricts();
|
||||||
|
}
|
||||||
|
return _districts!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<AdministrativeDivision>> get villages async {
|
||||||
|
if (_villages == null) {
|
||||||
|
await _loadVillages();
|
||||||
|
}
|
||||||
|
return _villages!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load provinces from JSON file
|
||||||
|
Future<void> _loadProvinces() async {
|
||||||
|
final jsonString = await rootBundle.loadString('public/jsons/provinsi.json');
|
||||||
|
final List<dynamic> jsonList = json.decode(jsonString);
|
||||||
|
|
||||||
|
_provinces = jsonList
|
||||||
|
.map((json) => AdministrativeDivision.fromProvinceJson(json))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load regencies/cities from JSON file
|
||||||
|
Future<void> _loadRegencies() async {
|
||||||
|
final jsonString = await rootBundle.loadString('public/jsons/kabupaten_kota.json');
|
||||||
|
final List<dynamic> jsonList = json.decode(jsonString);
|
||||||
|
|
||||||
|
_regencies = jsonList
|
||||||
|
.map((json) => AdministrativeDivision.fromRegencyJson(json))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load districts from JSON file
|
||||||
|
Future<void> _loadDistricts() async {
|
||||||
|
final jsonString = await rootBundle.loadString('public/jsons/kecamatan.json');
|
||||||
|
final List<dynamic> jsonList = json.decode(jsonString);
|
||||||
|
|
||||||
|
_districts = jsonList
|
||||||
|
.map((json) => AdministrativeDivision.fromDistrictJson(json))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load villages from JSON file
|
||||||
|
Future<void> _loadVillages() async {
|
||||||
|
final jsonString = await rootBundle.loadString('public/jsons/desa_kelurahan.json');
|
||||||
|
final List<dynamic> jsonList = json.decode(jsonString);
|
||||||
|
|
||||||
|
_villages = jsonList
|
||||||
|
.map((json) => AdministrativeDivision.fromVillageJson(json))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get provinces with pagination
|
||||||
|
Future<List<AdministrativeDivision>> getProvinces({int page = 1, int limit = 20}) async {
|
||||||
|
final allProvinces = await provinces;
|
||||||
|
|
||||||
|
final startIndex = (page - 1) * limit;
|
||||||
|
if (startIndex >= allProvinces.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final endIndex = startIndex + limit > allProvinces.length
|
||||||
|
? allProvinces.length
|
||||||
|
: startIndex + limit;
|
||||||
|
|
||||||
|
return allProvinces.sublist(startIndex, endIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get regencies/cities by province ID with pagination
|
||||||
|
Future<List<AdministrativeDivision>> getRegenciesByProvince(
|
||||||
|
String provinceId, {
|
||||||
|
int page = 1,
|
||||||
|
int limit = 20,
|
||||||
|
}) async {
|
||||||
|
final allRegencies = await regencies;
|
||||||
|
|
||||||
|
final filteredRegencies = allRegencies
|
||||||
|
.where((regency) => regency.parentId == provinceId)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final startIndex = (page - 1) * limit;
|
||||||
|
if (startIndex >= filteredRegencies.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final endIndex = startIndex + limit > filteredRegencies.length
|
||||||
|
? filteredRegencies.length
|
||||||
|
: startIndex + limit;
|
||||||
|
|
||||||
|
return filteredRegencies.sublist(startIndex, endIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get districts by regency ID with pagination
|
||||||
|
Future<List<AdministrativeDivision>> getDistrictsByRegency(
|
||||||
|
String regencyId, {
|
||||||
|
int page = 1,
|
||||||
|
int limit = 20,
|
||||||
|
}) async {
|
||||||
|
final allDistricts = await districts;
|
||||||
|
|
||||||
|
final filteredDistricts = allDistricts
|
||||||
|
.where((district) => district.parentId == regencyId)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final startIndex = (page - 1) * limit;
|
||||||
|
if (startIndex >= filteredDistricts.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final endIndex = startIndex + limit > filteredDistricts.length
|
||||||
|
? filteredDistricts.length
|
||||||
|
: startIndex + limit;
|
||||||
|
|
||||||
|
return filteredDistricts.sublist(startIndex, endIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get villages by district ID with pagination
|
||||||
|
Future<List<AdministrativeDivision>> getVillagesByDistrict(
|
||||||
|
String districtId, {
|
||||||
|
int page = 1,
|
||||||
|
int limit = 20,
|
||||||
|
}) async {
|
||||||
|
final allVillages = await villages;
|
||||||
|
|
||||||
|
final filteredVillages = allVillages
|
||||||
|
.where((village) => village.parentId == districtId)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final startIndex = (page - 1) * limit;
|
||||||
|
if (startIndex >= filteredVillages.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final endIndex = startIndex + limit > filteredVillages.length
|
||||||
|
? filteredVillages.length
|
||||||
|
: startIndex + limit;
|
||||||
|
|
||||||
|
return filteredVillages.sublist(startIndex, endIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search for locations across all levels
|
||||||
|
Future<List<AdministrativeDivision>> searchLocations(
|
||||||
|
String query, {
|
||||||
|
int limit = 40,
|
||||||
|
}) async {
|
||||||
|
if (query.length < 3) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final normalizedQuery = query.toLowerCase();
|
||||||
|
final results = <AdministrativeDivision>[];
|
||||||
|
|
||||||
|
// Search through provinces
|
||||||
|
final allProvinces = await provinces;
|
||||||
|
for (final province in allProvinces) {
|
||||||
|
if (province.name.toLowerCase().contains(normalizedQuery)) {
|
||||||
|
results.add(province);
|
||||||
|
if (results.length >= limit) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search through regencies
|
||||||
|
if (results.length < limit) {
|
||||||
|
final allRegencies = await regencies;
|
||||||
|
for (final regency in allRegencies) {
|
||||||
|
if (regency.name.toLowerCase().contains(normalizedQuery)) {
|
||||||
|
results.add(regency);
|
||||||
|
if (results.length >= limit) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search through districts
|
||||||
|
if (results.length < limit) {
|
||||||
|
final allDistricts = await districts;
|
||||||
|
for (final district in allDistricts) {
|
||||||
|
if (district.name.toLowerCase().contains(normalizedQuery)) {
|
||||||
|
results.add(district);
|
||||||
|
if (results.length >= limit) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search through villages
|
||||||
|
if (results.length < limit) {
|
||||||
|
final allVillages = await villages;
|
||||||
|
for (final village in allVillages) {
|
||||||
|
if (village.name.toLowerCase().contains(normalizedQuery)) {
|
||||||
|
results.add(village);
|
||||||
|
if (results.length >= limit) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the full location string (Province, Regency, District, Village)
|
||||||
|
Future<String> getFullLocationString(AdministrativeDivision division) async {
|
||||||
|
final parts = <String>[division.name];
|
||||||
|
|
||||||
|
switch (division.type) {
|
||||||
|
case DivisionType.village:
|
||||||
|
// Add district
|
||||||
|
final allDistricts = await districts;
|
||||||
|
final district = allDistricts.firstWhere(
|
||||||
|
(d) => d.id == division.parentId,
|
||||||
|
orElse: () => AdministrativeDivision(
|
||||||
|
id: '', name: '', type: DivisionType.district),
|
||||||
|
);
|
||||||
|
if (district.id.isNotEmpty) {
|
||||||
|
parts.add(district.name);
|
||||||
|
|
||||||
|
// Add regency
|
||||||
|
final allRegencies = await regencies;
|
||||||
|
final regency = allRegencies.firstWhere(
|
||||||
|
(r) => r.id == district.parentId,
|
||||||
|
orElse: () => AdministrativeDivision(
|
||||||
|
id: '', name: '', type: DivisionType.regency),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (regency.id.isNotEmpty) {
|
||||||
|
parts.add(regency.name);
|
||||||
|
|
||||||
|
// Add province
|
||||||
|
final allProvinces = await provinces;
|
||||||
|
final province = allProvinces.firstWhere(
|
||||||
|
(p) => p.id == regency.parentId,
|
||||||
|
orElse: () => AdministrativeDivision(
|
||||||
|
id: '', name: '', type: DivisionType.province),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (province.id.isNotEmpty) {
|
||||||
|
parts.add(province.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case DivisionType.district:
|
||||||
|
// Add regency
|
||||||
|
final allRegencies = await regencies;
|
||||||
|
final regency = allRegencies.firstWhere(
|
||||||
|
(r) => r.id == division.parentId,
|
||||||
|
orElse: () => AdministrativeDivision(
|
||||||
|
id: '', name: '', type: DivisionType.regency),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (regency.id.isNotEmpty) {
|
||||||
|
parts.add(regency.name);
|
||||||
|
|
||||||
|
// Add province
|
||||||
|
final allProvinces = await provinces;
|
||||||
|
final province = allProvinces.firstWhere(
|
||||||
|
(p) => p.id == regency.parentId,
|
||||||
|
orElse: () => AdministrativeDivision(
|
||||||
|
id: '', name: '', type: DivisionType.province),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (province.id.isNotEmpty) {
|
||||||
|
parts.add(province.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case DivisionType.regency:
|
||||||
|
// Add province
|
||||||
|
final allProvinces = await provinces;
|
||||||
|
final province = allProvinces.firstWhere(
|
||||||
|
(p) => p.id == division.parentId,
|
||||||
|
orElse: () => AdministrativeDivision(
|
||||||
|
id: '', name: '', type: DivisionType.province),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (province.id.isNotEmpty) {
|
||||||
|
parts.add(province.name);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case DivisionType.province:
|
||||||
|
// Already has the province name
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get division ID from name and type (for navigation)
|
||||||
|
Future<String?> getIdFromName(String name, DivisionType type) async {
|
||||||
|
switch (type) {
|
||||||
|
case DivisionType.province:
|
||||||
|
final allProvinces = await provinces;
|
||||||
|
final province = allProvinces.firstWhere(
|
||||||
|
(p) => p.name == name,
|
||||||
|
orElse: () => AdministrativeDivision(
|
||||||
|
id: '', name: '', type: DivisionType.province),
|
||||||
|
);
|
||||||
|
return province.id.isEmpty ? null : province.id;
|
||||||
|
|
||||||
|
case DivisionType.regency:
|
||||||
|
final allRegencies = await regencies;
|
||||||
|
final regency = allRegencies.firstWhere(
|
||||||
|
(r) => r.name == name,
|
||||||
|
orElse: () => AdministrativeDivision(
|
||||||
|
id: '', name: '', type: DivisionType.regency),
|
||||||
|
);
|
||||||
|
return regency.id.isEmpty ? null : regency.id;
|
||||||
|
|
||||||
|
case DivisionType.district:
|
||||||
|
final allDistricts = await districts;
|
||||||
|
final district = allDistricts.firstWhere(
|
||||||
|
(d) => d.name == name,
|
||||||
|
orElse: () => AdministrativeDivision(
|
||||||
|
id: '', name: '', type: DivisionType.district),
|
||||||
|
);
|
||||||
|
return district.id.isEmpty ? null : district.id;
|
||||||
|
|
||||||
|
case DivisionType.village:
|
||||||
|
final allVillages = await villages;
|
||||||
|
final village = allVillages.firstWhere(
|
||||||
|
(v) => v.name == name,
|
||||||
|
orElse: () => AdministrativeDivision(
|
||||||
|
id: '', name: '', type: DivisionType.village),
|
||||||
|
);
|
||||||
|
return village.id.isEmpty ? null : village.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
import 'package:sigap/src/features/auth/data/models/administrative_division.dart';
|
||||||
|
import 'package:sigap/src/features/map/data/repositories/administrative_data_service.dart';
|
||||||
|
|
||||||
|
class AdiminstrativeLocationRepository {
|
||||||
|
final AdministrativeRepository _dataService = AdministrativeRepository();
|
||||||
|
|
||||||
|
// Get divisions by type and parent ID
|
||||||
|
Future<List<AdministrativeDivision>> getDivisionsByType(
|
||||||
|
DivisionType type,
|
||||||
|
String? parentId,
|
||||||
|
) async {
|
||||||
|
switch (type) {
|
||||||
|
case DivisionType.province:
|
||||||
|
return await _dataService.getProvinces();
|
||||||
|
case DivisionType.regency:
|
||||||
|
return await _dataService.getRegenciesByProvince(parentId ?? '');
|
||||||
|
case DivisionType.district:
|
||||||
|
return await _dataService.getDistrictsByRegency(parentId ?? '');
|
||||||
|
case DivisionType.village:
|
||||||
|
return await _dataService.getVillagesByDistrict(parentId ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search across all divisions
|
||||||
|
Future<List<AdministrativeDivision>> searchLocations(String query) async {
|
||||||
|
return await _dataService.searchLocations(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the full location string for a division (for search results)
|
||||||
|
Future<String> getFullLocationString(AdministrativeDivision division) async {
|
||||||
|
return await _dataService.getFullLocationString(division);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a division ID from its name and type (for breadcrumb navigation)
|
||||||
|
Future<String?> getIdFromName(String name, DivisionType type) async {
|
||||||
|
return await _dataService.getIdFromName(name, type);
|
||||||
|
}
|
||||||
|
}
|
|
@ -24,7 +24,7 @@ class FormSectionHeader extends StatelessWidget {
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: TSizes.fontSizeLg,
|
fontSize: TSizes.fontSizeLg,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: TColors.textPrimary,
|
color: Theme.of(context).textTheme.bodyLarge?.color,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (subtitle != null) ...[
|
if (subtitle != null) ...[
|
||||||
|
|
|
@ -3,7 +3,8 @@ import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
class GenderSelection extends StatelessWidget {
|
class GenderSelection extends StatelessWidget {
|
||||||
final String selectedGender;
|
// Make selectedGender nullable with a default value
|
||||||
|
final String? selectedGender;
|
||||||
final Function(String?) onGenderChanged;
|
final Function(String?) onGenderChanged;
|
||||||
final String? errorText;
|
final String? errorText;
|
||||||
|
|
||||||
|
@ -16,6 +17,10 @@ class GenderSelection extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
// Use a null-safe approach with selectedGender
|
||||||
|
final effectiveGender = selectedGender ?? 'Male';
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
@ -23,43 +28,86 @@ class GenderSelection extends StatelessWidget {
|
||||||
const SizedBox(height: TSizes.xs),
|
const SizedBox(height: TSizes.xs),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
_buildRadioOption(context, 'Male', isDark, effectiveGender),
|
||||||
child: RadioListTile<String>(
|
const SizedBox(width: TSizes.spaceBtwItems),
|
||||||
title: const Text('Male'),
|
_buildRadioOption(context, 'Female', isDark, effectiveGender),
|
||||||
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)
|
if (errorText != null && errorText!.isNotEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: TSizes.xs, left: TSizes.xs),
|
padding: const EdgeInsets.only(top: TSizes.xs),
|
||||||
child: Text(
|
child: Text(
|
||||||
errorText!,
|
errorText!,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: TextStyle(
|
||||||
color: TColors.error,
|
color: TColors.error,
|
||||||
fontSize: 12,
|
fontSize: TSizes.fontSizeXs,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: TSizes.spaceBtwItems),
|
const SizedBox(height: TSizes.spaceBtwInputFields),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the _buildRadioOption method to use effectiveGender
|
||||||
|
Widget _buildRadioOption(
|
||||||
|
BuildContext context,
|
||||||
|
String gender,
|
||||||
|
bool isDark,
|
||||||
|
String effectiveGender,
|
||||||
|
) {
|
||||||
|
return Expanded(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => onGenderChanged(gender),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: TSizes.md,
|
||||||
|
vertical: TSizes.md,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color:
|
||||||
|
effectiveGender == gender
|
||||||
|
? Theme.of(context).primaryColor
|
||||||
|
: isDark
|
||||||
|
? TColors.darkGrey
|
||||||
|
: TColors.grey,
|
||||||
|
width: effectiveGender == gender ? 2 : 1,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusSm),
|
||||||
|
color:
|
||||||
|
effectiveGender == gender
|
||||||
|
? Theme.of(context).primaryColor.withOpacity(0.1)
|
||||||
|
: Colors.transparent,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
effectiveGender == gender
|
||||||
|
? Icons.radio_button_checked
|
||||||
|
: Icons.radio_button_off,
|
||||||
|
color:
|
||||||
|
effectiveGender == gender
|
||||||
|
? Theme.of(context).primaryColor
|
||||||
|
: isDark
|
||||||
|
? TColors.white
|
||||||
|
: TColors.dark,
|
||||||
|
size: TSizes.iconSm,
|
||||||
|
),
|
||||||
|
const SizedBox(width: TSizes.xs),
|
||||||
|
Text(
|
||||||
|
gender,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color:
|
||||||
|
effectiveGender == gender
|
||||||
|
? Theme.of(context).primaryColor
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:sigap/src/utils/constants/colors.dart';
|
|
||||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
import 'package:sigap/src/utils/helpers/helper_functions.dart';
|
|
||||||
|
|
||||||
class CustomTextField extends StatelessWidget {
|
class CustomTextField extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
|
@ -39,20 +37,26 @@ class CustomTextField extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final Color effectiveAccentColor = accentColor ?? TColors.primary;
|
// Use theme's primary color by default or the provided accent color
|
||||||
final isDark = THelperFunctions.isDarkMode(context);
|
final Color effectiveAccentColor =
|
||||||
|
accentColor ?? Theme.of(context).primaryColor;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// Label text using theme typography
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.copyWith(
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: isDark ? TColors.white : TColors.textPrimary,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: TSizes.sm),
|
const SizedBox(height: TSizes.sm),
|
||||||
|
|
||||||
|
// TextFormField with theme-aware styling
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
validator: validator,
|
validator: validator,
|
||||||
|
@ -62,14 +66,13 @@ class CustomTextField extends StatelessWidget {
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
obscureText: obscureText,
|
obscureText: obscureText,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
// Use the theme's text style
|
||||||
color: isDark ? TColors.white : TColors.textPrimary,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: hintText,
|
hintText: hintText,
|
||||||
hintStyle: Theme.of(
|
hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
context,
|
color: isDark ? Colors.grey[400] : Colors.grey[600],
|
||||||
).textTheme.bodyMedium?.copyWith(color: TColors.textSecondary),
|
),
|
||||||
errorText:
|
errorText:
|
||||||
errorText != null && errorText!.isNotEmpty ? errorText : null,
|
errorText != null && errorText!.isNotEmpty ? errorText : null,
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
@ -79,14 +82,27 @@ class CustomTextField extends StatelessWidget {
|
||||||
prefixIcon: prefixIcon,
|
prefixIcon: prefixIcon,
|
||||||
suffixIcon: suffixIcon,
|
suffixIcon: suffixIcon,
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: isDark ? TColors.darkContainer : TColors.lightContainer,
|
// Use theme colors for filling based on brightness
|
||||||
|
fillColor:
|
||||||
|
isDark
|
||||||
|
? Theme.of(context).cardColor
|
||||||
|
: Theme.of(context).inputDecorationTheme.fillColor ??
|
||||||
|
Colors.grey[100],
|
||||||
|
|
||||||
|
// Use theme-aware border styling
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
|
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
|
||||||
borderSide: BorderSide(color: TColors.borderPrimary, width: 1),
|
borderSide: BorderSide(
|
||||||
|
color: Theme.of(context).dividerColor,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
|
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
|
||||||
borderSide: BorderSide(color: TColors.borderPrimary, width: 1),
|
borderSide: BorderSide(
|
||||||
|
color: Theme.of(context).dividerColor,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
|
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
|
||||||
|
@ -94,24 +110,20 @@ class CustomTextField extends StatelessWidget {
|
||||||
),
|
),
|
||||||
errorBorder: OutlineInputBorder(
|
errorBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
|
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
|
||||||
borderSide: BorderSide(color: TColors.error, width: 1),
|
borderSide: BorderSide(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
focusedErrorBorder: OutlineInputBorder(
|
focusedErrorBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
|
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
|
||||||
borderSide: BorderSide(color: TColors.error, width: 1.5),
|
borderSide: BorderSide(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// if (errorText != null && errorText!.isNotEmpty)
|
|
||||||
// Padding(
|
|
||||||
// padding: const EdgeInsets.only(top: 6.0),
|
|
||||||
// child: Text(
|
|
||||||
// errorText!,
|
|
||||||
// style: Theme.of(
|
|
||||||
// context,
|
|
||||||
// ).textTheme.bodySmall?.copyWith(color: TColors.error),
|
|
||||||
// ),
|
|
||||||
// ),
|
|
||||||
const SizedBox(height: TSizes.spaceBtwInputFields),
|
const SizedBox(height: TSizes.spaceBtwInputFields),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
@ -42,6 +42,8 @@ class Endpoints {
|
||||||
'https://rekognition.$awsRegion.amazonaws.com';
|
'https://rekognition.$awsRegion.amazonaws.com';
|
||||||
|
|
||||||
// Supabase Edge Functions
|
// Supabase Edge Functions
|
||||||
static String get detectFace => '$supabaseUrl/function/v1/detect-face';
|
static String get detectFace =>
|
||||||
static String get verifyFace => '$supabaseUrl/function/v1/verify-face';
|
'https://bhfzrlgxqkbkjepvqeva.supabase.co/functions/v1/detect-face';
|
||||||
|
static String get verifyFace =>
|
||||||
|
'https://bhfzrlgxqkbkjepvqeva.supabase.co/functions/v1/verify-face';
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -149,6 +149,11 @@ flutter:
|
||||||
- assets/icons/categories/
|
- assets/icons/categories/
|
||||||
- assets/images/animations/
|
- assets/images/animations/
|
||||||
- assets/images/on_boarding_images/
|
- assets/images/on_boarding_images/
|
||||||
|
# JSON Files
|
||||||
|
- public/jsons/provinsi.json
|
||||||
|
- public/jsons/kabupaten_kota.json
|
||||||
|
- public/jsons/kecamatan.json
|
||||||
|
- public/jsons/desa_kelurahan.json
|
||||||
|
|
||||||
#--------------- LOCAL FONTS ------------------#
|
#--------------- LOCAL FONTS ------------------#
|
||||||
fonts:
|
fonts:
|
||||||
|
|
|
@ -8,6 +8,9 @@ const AWS_REGION = Deno.env.get('AWS_REGION');
|
||||||
const AWS_ACCESS_KEY = Deno.env.get('AWS_ACCESS_KEY');
|
const AWS_ACCESS_KEY = Deno.env.get('AWS_ACCESS_KEY');
|
||||||
const AWS_SECRET_KEY = Deno.env.get('AWS_SECRET_KEY');
|
const AWS_SECRET_KEY = Deno.env.get('AWS_SECRET_KEY');
|
||||||
serve(async (req)=>{
|
serve(async (req)=>{
|
||||||
|
console.log('AWS_REGION:', AWS_REGION);
|
||||||
|
console.log('AWS_ACCESS_KEY:', AWS_ACCESS_KEY?.slice(0, 5)); // for security, partial only
|
||||||
|
console.log('AWS_SECRET_KEY:', AWS_SECRET_KEY?.slice(0, 5)); // for security, partial only
|
||||||
try {
|
try {
|
||||||
// Check if we have AWS credentials
|
// Check if we have AWS credentials
|
||||||
if (!AWS_REGION || !AWS_ACCESS_KEY || !AWS_SECRET_KEY) {
|
if (!AWS_REGION || !AWS_ACCESS_KEY || !AWS_SECRET_KEY) {
|
||||||
|
|
Loading…
Reference in New Issue