feat: Implement registration form and verification steps

- Added FormRegistrationScreen for user profile completion with step navigation.
- Created SelfieVerificationStep for capturing and validating user selfies.
- Implemented OfficerInfoStep and UnitInfoStep for officer-specific information input.
- Introduced PanicButtonController and PanicButtonPage for emergency alert functionality.
- Developed CustomElevatedButton and CustomBottomNavigationBar for enhanced UI components.
This commit is contained in:
vergiLgood1 2025-05-23 22:30:06 +07:00
parent 512b29c54d
commit 5c3faac8c3
61 changed files with 3838 additions and 2086 deletions

View File

@ -1,11 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart'; import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart';
import 'package:sigap/app.dart'; import 'package:sigap/app.dart';
import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'navigation_menu.dart';
Future<void> main() async { Future<void> main() async {
final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
@ -43,3 +48,4 @@ Future<void> main() async {
runApp(const App()); runApp(const App());
} }

View File

@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/account/presentation/pages/account_page.dart';
import 'package:sigap/src/features/history/presentation/pages/history_page.dart';
import 'package:sigap/src/features/home/presentation/pages/home_page.dart';
import 'package:sigap/src/features/panic/presentation/pages/panic_button_page.dart';
import 'package:sigap/src/features/search/presentation/pages/search_page.dart';
import 'package:sigap/src/shared/widgets/navigation/custom_bottom_navigation_bar.dart';
class NavigationMenu extends StatelessWidget {
const NavigationMenu({super.key});
@override
Widget build(BuildContext context) {
// Using GetX controller to manage navigation state
final controller = Get.put(NavigationController());
return Scaffold(
body: Obx(
() => IndexedStack(
index: controller.selectedIndex.value,
children: const [
HomePage(),
SearchPage(),
PanicButtonPage(),
HistoryPage(),
AccountPage(),
],
),
),
bottomNavigationBar: const CustomBottomNavigationBar(),
);
}
}
class NavigationController extends GetxController {
static NavigationController get instance => Get.find();
// Observable variable to track the current selected index
final Rx<int> selectedIndex = 2.obs; // Start with PanicButtonPage (index 2)
// Method to change selected index
void changeIndex(int index) {
selectedIndex.value = index;
}
}

View File

@ -1,27 +1,23 @@
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';
class ServiceBindings extends Bindings { class ServiceBindings extends Bindings {
@override @override
Future<void> dependencies() async { Future<void> dependencies() async {
// Initialize background service // Initialize background service
final locationService = await BackgroundService.instance final locationService = await BackgroundService.instance
.compute<LocationService, void>((message) => LocationService(), null); .compute<LocationService, void>((message) => LocationService(), null);
final biometricService = await BackgroundService.instance final biometricService = await BackgroundService.instance
.compute<BiometricService, void>((message) => BiometricService(), null); .compute<BiometricService, void>((message) => BiometricService(), null);
// Initialize services // Initialize services
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,
);
} }
} }

View File

@ -1,10 +1,13 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/navigation_menu.dart';
import 'package:sigap/src/features/auth/presentasion/pages/email-verification/email_verification_screen.dart'; import 'package:sigap/src/features/auth/presentasion/pages/email-verification/email_verification_screen.dart';
import 'package:sigap/src/features/auth/presentasion/pages/forgot-password/forgot_password.dart'; import 'package:sigap/src/features/auth/presentasion/pages/forgot-password/forgot_password.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/registraion_form_screen.dart'; import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/id_card_verification_step.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/registraion_form_screen.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/selfie_verification_step.dart';
import 'package:sigap/src/features/auth/presentasion/pages/signin/signin_screen.dart'; import 'package:sigap/src/features/auth/presentasion/pages/signin/signin_screen.dart';
import 'package:sigap/src/features/auth/presentasion/pages/signup/signup_screen.dart';
import 'package:sigap/src/features/auth/presentasion/pages/signup/signup_with_role_screen.dart'; import 'package:sigap/src/features/auth/presentasion/pages/signup/signup_with_role_screen.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/liveness_detection_screen.dart';
import 'package:sigap/src/features/onboarding/presentasion/pages/location-warning/location_warning_screen.dart'; import 'package:sigap/src/features/onboarding/presentasion/pages/location-warning/location_warning_screen.dart';
import 'package:sigap/src/features/onboarding/presentasion/pages/onboarding/onboarding_screen.dart'; import 'package:sigap/src/features/onboarding/presentasion/pages/onboarding/onboarding_screen.dart';
import 'package:sigap/src/features/onboarding/presentasion/pages/role-selection/role_selection_screen.dart'; import 'package:sigap/src/features/onboarding/presentasion/pages/role-selection/role_selection_screen.dart';
@ -33,13 +36,11 @@ class AppPages {
GetPage(name: AppRoutes.signIn, page: () => const SignInScreen()), GetPage(name: AppRoutes.signIn, page: () => const SignInScreen()),
GetPage(name: AppRoutes.signUp, page: () => const SignUpScreen()),
GetPage( GetPage(
name: AppRoutes.signupWithRole, name: AppRoutes.signupWithRole,
page: () => const SignupWithRoleScreen(), page: () => const SignupWithRoleScreen(),
), ),
GetPage( GetPage(
name: AppRoutes.emailVerification, name: AppRoutes.emailVerification,
page: () => const EmailVerificationScreen(), page: () => const EmailVerificationScreen(),
@ -50,11 +51,16 @@ class AppPages {
page: () => const ForgotPasswordScreen(), page: () => const ForgotPasswordScreen(),
), ),
GetPage( GetPage(
name: AppRoutes.locationWarning, name: AppRoutes.locationWarning,
page: () => const LocationWarningScreen(), page: () => const LocationWarningScreen(),
) ),
GetPage(name: AppRoutes.navigationMenu, page: () => const NavigationMenu()),
GetPage(
name: AppRoutes.livenessDetection,
page: () => const LivenessDetectionPage(),
),
]; ];
} }

View File

@ -80,90 +80,6 @@ class EdgeFunctionService {
throw lastException ?? Exception('Face detection failed'); 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 /// Compares two face images and returns a comparison result with retries
Future<FaceComparisonResult> compareFaces( Future<FaceComparisonResult> compareFaces(
XFile sourceImage, XFile sourceImage,

View File

@ -1,87 +0,0 @@
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);
}
}

View File

@ -43,7 +43,7 @@ class LocationService extends GetxService {
//when going to the background //when going to the background
foregroundNotificationConfig: const ForegroundNotificationConfig( foregroundNotificationConfig: const ForegroundNotificationConfig(
notificationText: notificationText:
"Example app will continue to receive your location even when you aren't using it", "Sigap app will continue to receive your location even when you aren't using it",
notificationTitle: "Running in Background", notificationTitle: "Running in Background",
enableWakeLock: true, enableWakeLock: true,
), ),

View File

@ -22,6 +22,33 @@ class FaceModel {
final String? gender; final String? gender;
final double? genderConfidence; final double? genderConfidence;
/// Facial expressions
final bool? isSmiling;
final double? smileConfidence;
final bool? areEyesOpen;
final double? eyesOpenConfidence;
final bool? isMouthOpen;
final double? mouthOpenConfidence;
/// Accessories
final bool? hasEyeglasses;
final bool? hasSunglasses;
final bool? hasBeard;
final bool? hasMustache;
/// Emotions (primary emotion)
final String? primaryEmotion;
final double? emotionConfidence;
/// Face pose
final double? roll;
final double? yaw;
final double? pitch;
/// Image quality
final double? brightness;
final double? sharpness;
/// Liveness detection data /// Liveness detection data
final bool isLive; final bool isLive;
final double livenessConfidence; final double livenessConfidence;
@ -41,71 +68,226 @@ class FaceModel {
this.maxAge, this.maxAge,
this.gender, this.gender,
this.genderConfidence, this.genderConfidence,
this.isSmiling,
this.smileConfidence,
this.areEyesOpen,
this.eyesOpenConfidence,
this.isMouthOpen,
this.mouthOpenConfidence,
this.hasEyeglasses,
this.hasSunglasses,
this.hasBeard,
this.hasMustache,
this.primaryEmotion,
this.emotionConfidence,
this.roll,
this.yaw,
this.pitch,
this.brightness,
this.sharpness,
this.isLive = false, this.isLive = false,
this.livenessConfidence = 0.0, this.livenessConfidence = 0.0,
this.attributes, this.attributes,
this.message = '', this.message = '',
}); });
/// Constructor from edge function response /// Constructor from edge function response based on Amazon Rekognition format
factory FaceModel.fromEdgeFunction( factory FaceModel.fromEdgeFunction(
XFile image,
Map<String, dynamic> response,
) {
// Check if we're parsing a face from the faceDetails array
if (response.containsKey('BoundingBox')) {
return _parseRekognitionFace(image, response);
}
// Check if we have success and faceDetails
if (response.containsKey('success') &&
response['success'] == true &&
response.containsKey('faceDetails')) {
final faceDetails = response['faceDetails'];
if (faceDetails is List && faceDetails.isNotEmpty) {
return _parseRekognitionFace(image, faceDetails[0]);
}
}
// Fallback for unknown format
return FaceModel(
imagePath: image.path,
faceId: 'face-${DateTime.now().millisecondsSinceEpoch}',
confidence:
response['Confidence'] != null
? (response['Confidence'] as num).toDouble() / 100.0
: 0.0,
boundingBox: {'x': 0.0, 'y': 0.0, 'width': 0.0, 'height': 0.0},
message: 'Unknown face format',
);
}
// Private helper to parse a Rekognition face detail
static FaceModel _parseRekognitionFace(
XFile image, XFile image,
Map<String, dynamic> faceData, Map<String, dynamic> faceData,
) { ) {
// Extract faceId if available // Extract bounding box
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 = { Map<String, double> boundingBox = {
'x': 0.0, 'x': 0.0,
'y': 0.0, 'y': 0.0,
'width': 0.0, 'width': 0.0,
'height': 0.0, 'height': 0.0,
}; };
if (faceData['boundingBox'] != null || faceData['bounding_box'] != null) { if (faceData['BoundingBox'] != null) {
final box = faceData['boundingBox'] ?? faceData['bounding_box']; final box = faceData['BoundingBox'];
boundingBox = { boundingBox = {
'x': (box['left'] ?? box['x'] ?? 0.0).toDouble(), 'x': (box['Left'] ?? 0.0).toDouble(),
'y': (box['top'] ?? box['y'] ?? 0.0).toDouble(), 'y': (box['Top'] ?? 0.0).toDouble(),
'width': (box['width'] ?? 0.0).toDouble(), 'width': (box['Width'] ?? 0.0).toDouble(),
'height': (box['height'] ?? 0.0).toDouble(), 'height': (box['Height'] ?? 0.0).toDouble(),
}; };
} }
// Extract age information if available // Extract age range
int? minAge; int? minAge;
int? maxAge; int? maxAge;
if (faceData['age'] != null) { if (faceData['AgeRange'] != null) {
if (faceData['age'] is Map && faceData['age']['range'] != null) { minAge =
minAge = faceData['age']['range']['low']; faceData['AgeRange']['Low'] is num
maxAge = faceData['age']['range']['high']; ? (faceData['AgeRange']['Low'] as num).toInt()
} else if (faceData['age'] is num) { : null;
// Single age value maxAge =
final age = (faceData['age'] as num).toInt(); faceData['AgeRange']['High'] is num
minAge = age - 5; ? (faceData['AgeRange']['High'] as num).toInt()
maxAge = age + 5; : null;
}
} }
// Extract gender if available // Extract gender
String? gender; String? gender;
double? genderConfidence; double? genderConfidence;
if (faceData['gender'] != null) { if (faceData['Gender'] != null) {
if (faceData['gender'] is Map) { gender = faceData['Gender']['Value'];
gender = faceData['gender']['value']; genderConfidence =
genderConfidence = faceData['gender']['confidence'] / 100.0; faceData['Gender']['Confidence'] is num
} else if (faceData['gender'] is String) { ? (faceData['Gender']['Confidence'] as num).toDouble() / 100.0
gender = faceData['gender']; : null;
genderConfidence = 0.9; // Default confidence
}
} }
// Create the face model // Extract expressions
bool? isSmiling;
double? smileConfidence;
if (faceData['Smile'] != null) {
isSmiling = faceData['Smile']['Value'];
smileConfidence =
faceData['Smile']['Confidence'] is num
? (faceData['Smile']['Confidence'] as num).toDouble() / 100.0
: null;
}
bool? eyesOpen;
double? eyesOpenConfidence;
if (faceData['EyesOpen'] != null) {
eyesOpen = faceData['EyesOpen']['Value'];
eyesOpenConfidence =
faceData['EyesOpen']['Confidence'] is num
? (faceData['EyesOpen']['Confidence'] as num).toDouble() / 100.0
: null;
}
bool? mouthOpen;
double? mouthOpenConfidence;
if (faceData['MouthOpen'] != null) {
mouthOpen = faceData['MouthOpen']['Value'];
mouthOpenConfidence =
faceData['MouthOpen']['Confidence'] is num
? (faceData['MouthOpen']['Confidence'] as num).toDouble() / 100.0
: null;
}
// Extract accessories
bool? hasEyeglasses;
if (faceData['Eyeglasses'] != null) {
hasEyeglasses = faceData['Eyeglasses']['Value'];
}
bool? hasSunglasses;
if (faceData['Sunglasses'] != null) {
hasSunglasses = faceData['Sunglasses']['Value'];
}
bool? hasBeard;
if (faceData['Beard'] != null) {
hasBeard = faceData['Beard']['Value'];
}
bool? hasMustache;
if (faceData['Mustache'] != null) {
hasMustache = faceData['Mustache']['Value'];
}
// Extract emotions
String? primaryEmotion;
double? emotionConfidence;
if (faceData['Emotions'] != null &&
faceData['Emotions'] is List &&
(faceData['Emotions'] as List).isNotEmpty) {
final topEmotion = faceData['Emotions'][0];
primaryEmotion = topEmotion['Type'];
emotionConfidence =
topEmotion['Confidence'] is num
? (topEmotion['Confidence'] as num).toDouble() / 100.0
: null;
}
// Extract pose
double? roll;
double? yaw;
double? pitch;
if (faceData['Pose'] != null) {
roll =
faceData['Pose']['Roll'] is num
? (faceData['Pose']['Roll'] as num).toDouble()
: null;
yaw =
faceData['Pose']['Yaw'] is num
? (faceData['Pose']['Yaw'] as num).toDouble()
: null;
pitch =
faceData['Pose']['Pitch'] is num
? (faceData['Pose']['Pitch'] as num).toDouble()
: null;
}
// Extract quality
double? brightness;
double? sharpness;
if (faceData['Quality'] != null) {
brightness =
faceData['Quality']['Brightness'] is num
? (faceData['Quality']['Brightness'] as num).toDouble()
: null;
sharpness =
faceData['Quality']['Sharpness'] is num
? (faceData['Quality']['Sharpness'] as num).toDouble()
: null;
}
// Get the confidence score
double confidence =
faceData['Confidence'] is num
? (faceData['Confidence'] as num).toDouble() / 100.0
: 0.7;
// Generate a unique face ID
final faceId = 'face-${DateTime.now().millisecondsSinceEpoch}';
// Create message about face quality
String message = 'Face detected successfully';
if (eyesOpen == true &&
isSmiling == true &&
sharpness != null &&
sharpness > 80) {
message = 'High quality face detected';
}
return FaceModel( return FaceModel(
imagePath: image.path, imagePath: image.path,
faceId: faceId, faceId: faceId,
@ -115,7 +297,25 @@ class FaceModel {
maxAge: maxAge, maxAge: maxAge,
gender: gender, gender: gender,
genderConfidence: genderConfidence, genderConfidence: genderConfidence,
isSmiling: isSmiling,
smileConfidence: smileConfidence,
areEyesOpen: eyesOpen,
eyesOpenConfidence: eyesOpenConfidence,
isMouthOpen: mouthOpen,
mouthOpenConfidence: mouthOpenConfidence,
hasEyeglasses: hasEyeglasses,
hasSunglasses: hasSunglasses,
hasBeard: hasBeard,
hasMustache: hasMustache,
primaryEmotion: primaryEmotion,
emotionConfidence: emotionConfidence,
roll: roll,
yaw: yaw,
pitch: pitch,
brightness: brightness,
sharpness: sharpness,
attributes: faceData, attributes: faceData,
message: message,
); );
} }
@ -182,6 +382,23 @@ class FaceModel {
'maxAge': maxAge, 'maxAge': maxAge,
'gender': gender, 'gender': gender,
'genderConfidence': genderConfidence, 'genderConfidence': genderConfidence,
'isSmiling': isSmiling,
'smileConfidence': smileConfidence,
'areEyesOpen': areEyesOpen,
'eyesOpenConfidence': eyesOpenConfidence,
'isMouthOpen': isMouthOpen,
'mouthOpenConfidence': mouthOpenConfidence,
'hasEyeglasses': hasEyeglasses,
'hasSunglasses': hasSunglasses,
'hasBeard': hasBeard,
'hasMustache': hasMustache,
'primaryEmotion': primaryEmotion,
'emotionConfidence': emotionConfidence,
'roll': roll,
'yaw': yaw,
'pitch': pitch,
'brightness': brightness,
'sharpness': sharpness,
'isLive': isLive, 'isLive': isLive,
'livenessConfidence': livenessConfidence, 'livenessConfidence': livenessConfidence,
'message': message, 'message': message,
@ -203,6 +420,15 @@ class FaceComparisonResult {
/// Confidence level of the match (0.0-1.0) /// Confidence level of the match (0.0-1.0)
final double confidence; final double confidence;
/// Similarity score (0-100)
final double similarity;
/// Threshold used for matching
final double similarityThreshold;
/// Confidence level as text (HIGH, MEDIUM, LOW)
final String? confidenceLevel;
/// Message describing the comparison result /// Message describing the comparison result
final String message; final String message;
@ -213,6 +439,9 @@ class FaceComparisonResult {
required this.targetFace, required this.targetFace,
required this.isMatch, required this.isMatch,
required this.confidence, required this.confidence,
this.similarity = 0.0,
this.similarityThreshold = 0.0,
this.confidenceLevel,
required this.message, required this.message,
}); });
@ -222,25 +451,52 @@ class FaceComparisonResult {
FaceModel targetFace, FaceModel targetFace,
Map<String, dynamic> response, Map<String, dynamic> response,
) { ) {
bool isMatch = response['isMatch'] ?? false; // Check if the response is valid
double confidence = 0.0; if (!response.containsKey('success') || response['success'] != true) {
return FaceComparisonResult.error(
if (response['confidence'] != null || response['similarity'] != null) { sourceFace,
confidence = targetFace,
((response['confidence'] ?? response['similarity']) ?? 0.0) / 100.0; 'Invalid or failed response',
);
} }
// Extract match result
final bool isMatch = response['matched'] ?? false;
// Extract similarity and threshold
final double similarity = (response['similarity'] ?? 0.0).toDouble();
final double similarityThreshold =
(response['similarityThreshold'] ?? 0.0).toDouble();
// Calculate normalized confidence (0.0-1.0)
final double confidence = similarity / 100.0;
// Get confidence level if available
final String? confidenceLevel = response['confidence'];
String message = // Generate appropriate message
response['message'] ?? String message;
(isMatch if (isMatch) {
? 'Faces match with ${(confidence * 100).toStringAsFixed(1)}% confidence' message = 'Faces match with ${similarity.toStringAsFixed(1)}% similarity';
: 'Faces do not match'); if (confidenceLevel != null) {
message += ' ($confidenceLevel confidence)';
}
} else {
message =
'Faces do not match. Similarity: ${similarity.toStringAsFixed(1)}%';
if (similarityThreshold > 0) {
message += ' (threshold: ${similarityThreshold.toStringAsFixed(1)}%)';
}
}
return FaceComparisonResult( return FaceComparisonResult(
sourceFace: sourceFace, sourceFace: sourceFace,
targetFace: targetFace, targetFace: targetFace,
isMatch: isMatch, isMatch: isMatch,
confidence: confidence, confidence: confidence,
similarity: similarity,
similarityThreshold: similarityThreshold,
confidenceLevel: confidenceLevel,
message: message, message: message,
); );
} }

View File

@ -5,8 +5,8 @@ import 'package:logger/logger.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/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';
import 'package:sigap/src/features/personalization/data/models/models/user_metadata_model.dart';
import 'package:sigap/src/features/auth/presentasion/pages/signin/signin_screen.dart'; import 'package:sigap/src/features/auth/presentasion/pages/signin/signin_screen.dart';
import 'package:sigap/src/features/personalization/data/models/models/user_metadata_model.dart';
import 'package:sigap/src/utils/constants/app_routes.dart'; import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/exceptions/exceptions.dart'; import 'package:sigap/src/utils/exceptions/exceptions.dart';
import 'package:sigap/src/utils/exceptions/format_exceptions.dart'; import 'package:sigap/src/utils/exceptions/format_exceptions.dart';
@ -192,6 +192,68 @@ class AuthenticationRepository extends GetxController {
} }
} }
// Login with email and password
Future<User?> loginWithEmailPassword({
required String email,
required String password,
}) async {
try {
final response = await _supabase.auth.signInWithPassword(
email: email,
password: password,
);
// Store user in session
final user = response.user;
// Check for errors
if (user == null) {
throw 'User is null after sign in';
}
// Return user
return user;
} on AuthException catch (e) {
throw TExceptions(e.message).message;
} on FormatException catch (_) {
throw const TFormatException().message;
} on PlatformException catch (e) {
throw TPlatformException(e.code).message;
} catch (e) {
throw e.toString();
}
}
// Sign in with Google
// Future<User?> signInWithGoogle() async {
// try {
// // Use Supabase auth to sign in with Google
// final response = await _supabase.auth.signInWithOAuth(
// Provider.google,
// redirectTo: kIsWeb ? null : 'io.supabase.sigap://login-callback/',
// queryParams: {'access_type': 'offline'},
// );
// // Check if sign in was successful
// if (!response.isSuccess()) {
// throw 'Google sign in failed';
// }
// // Get session
// final session = _supabase.auth.currentSession;
// if (session == null) {
// throw 'No session after Google sign in';
// }
// // Return user
// return session.user;
// } on AuthException catch (e) {
// throw TExceptions(e.message).message;
// } catch (e) {
// throw e.toString();
// }
// }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// SESSION MANAGEMENT // SESSION MANAGEMENT
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -1,17 +1,16 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/email_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/basic/email_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/forgot_password_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/basic/forgot_password_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signin_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/basic/signin_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup_with_role_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/basic/signup_with_role_controller.dart';
class AuthControllerBindings extends Bindings { class AuthControllerBindings extends Bindings {
@override @override
void dependencies() { void dependencies() {
// Register all feature auth controllers // Register all feature auth controllers
Get.lazyPut(() => SignInController(), fenix: true); Get.lazyPut(() => SignInController(), fenix: true);
Get.lazyPut(() => SignUpController(), fenix: true);
Get.lazyPut(() => SignupWithRoleController(), fenix: true); Get.lazyPut(() => SignupWithRoleController(), fenix: true);
Get.lazyPut(() => FormRegistrationController(), fenix: true); Get.lazyPut(() => FormRegistrationController(), fenix: true);
Get.lazyPut(() => EmailVerificationController(), fenix: true); Get.lazyPut(() => EmailVerificationController(), fenix: true);

View File

@ -4,17 +4,16 @@ import 'package:get_storage/get_storage.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:sigap/src/cores/services/supabase_service.dart'; import 'package:sigap/src/cores/services/supabase_service.dart';
import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart'; import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/id-card-verification/id_card_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/identity-verification/identity_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/officer_info_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/officer-information/officer_info_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/personal_info_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/officer-information/unit_info_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/selfie_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/selfie_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/unit_info_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/viewer-information/personal_info_controller.dart';
import 'package:sigap/src/features/daily-ops/data/models/index.dart'; import 'package:sigap/src/features/daily-ops/data/models/index.dart';
import 'package:sigap/src/features/personalization/data/models/index.dart'; import 'package:sigap/src/features/personalization/data/models/index.dart';
import 'package:sigap/src/features/personalization/data/models/models/user_metadata_model.dart'; import 'package:sigap/src/features/personalization/data/models/models/user_metadata_model.dart';
import 'package:sigap/src/features/personalization/data/repositories/roles_repository.dart'; import 'package:sigap/src/features/personalization/data/repositories/roles_repository.dart';
import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/constants/num_int.dart'; import 'package:sigap/src/utils/constants/num_int.dart';
import 'package:sigap/src/utils/popups/loaders.dart'; import 'package:sigap/src/utils/popups/loaders.dart';
@ -55,6 +54,11 @@ class FormRegistrationController extends GetxController {
// Loading state // Loading state
final RxBool isLoading = false.obs; final RxBool isLoading = false.obs;
// Form submission states
final RxBool isSubmitting = RxBool(false);
final RxString submitMessage = RxString('');
final RxBool isSubmitSuccess = RxBool(false);
// Data to be passed between steps // Data to be passed between steps
final Rx<dynamic> idCardData = Rx<dynamic>(null); final Rx<dynamic> idCardData = Rx<dynamic>(null);
@ -460,63 +464,12 @@ 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');
} }
} }
// Go to next step
// void nextStep() async {
// final isValid = formKey.currentState?.validate() ?? false;
// if (isValid) {
// // Validate based on the current step
// if (currentStep.value == 0) {
// // Personal Info Step
// personalInfoController.validate();
// if (!personalInfoController.isFormValid.value) return;
// } else if (currentStep.value == 1) {
// // ID Card Verification Step
// final idCardController = Get.find<IdCardVerificationController>();
// if (!idCardController.validate()) return;
// // Pass data to next step if validation succeeded
// passIdCardDataToNextStep();
// } else if (currentStep.value == 2) {
// // Selfie Verification Step
// final selfieController = Get.find<SelfieVerificationController>();
// if (!selfieController.validate()) return;
// } else if (currentStep.value == 3) {
// if (selectedRole.value!.isOfficer) {
// // Officer Info Step
// final officerInfoController = Get.find<OfficerInfoController>();
// if (!officerInfoController.validate()) return;
// } else {
// // Identity Verification Step
// final identityVerificationController =
// Get.find<IdentityVerificationController>();
// if (!identityVerificationController.validate()) return;
// }
// } else if (currentStep.value == 4 && selectedRole.value!.isOfficer) {
// // Unit Info Step
// final unitInfoController = Get.find<UnitInfoController>();
// if (!unitInfoController.validate()) return;
// }
// if (currentStep.value == totalSteps - 1) {
// // This is the last step, submit the form
// _submitForm();
// } else {
// // Move to the next step
// if (currentStep.value < totalSteps - 1) {
// currentStep.value++;
// }
// }
// }
// }
// Go to next step - fixed implementation // Go to next step - fixed implementation
void nextStep() { void nextStep() {
// Special case for step 1 (ID Card step) // Special case for step 1 (ID Card step)
@ -568,6 +521,7 @@ class FormRegistrationController extends GetxController {
// Proceed to next step // Proceed to next step
if (currentStep.value < totalSteps - 1) { if (currentStep.value < totalSteps - 1) {
// Fixed missing parenthesis
currentStep.value++; currentStep.value++;
} else { } else {
submitForm(); submitForm();
@ -625,60 +579,6 @@ class FormRegistrationController extends GetxController {
} }
} }
// Submit the complete form
Future<void> submitForm() async {
// Validate all steps
bool isFormValid = true;
for (int i = 0; i < totalSteps; i++) {
currentStep.value = i;
if (!validateCurrentStep()) {
isFormValid = false;
break;
}
}
if (!isFormValid) return;
try {
isLoading.value = false;
// Prepare UserMetadataModel with all collected data
collectAllFormData();
// Complete the user profile using AuthenticationRepository
await AuthenticationRepository.instance.completeUserProfile(
userMetadata.value,
);
// Show success
Get.toNamed(
AppRoutes.stateScreen,
arguments: {
'type': 'success',
'title': 'Registration Completed',
'message': 'Your profile has been successfully created.',
'buttonText': 'Continue',
'onButtonPressed':
() => AuthenticationRepository.instance.screenRedirect(),
},
);
} catch (e) {
Get.toNamed(
AppRoutes.stateScreen,
arguments: {
'type': 'error',
'title': 'Registration Failed',
'message':
'There was an error completing your profile: ${e.toString()}',
'buttonText': 'Try Again',
'onButtonPressed': () => Get.back(),
},
);
} finally {
isLoading.value = false;
}
}
// Add this method to collect all form data // Add this method to collect all form data
void collectAllFormData() { void collectAllFormData() {
final isOfficerRole = selectedRole.value?.isOfficer ?? false; final isOfficerRole = selectedRole.value?.isOfficer ?? false;
@ -771,4 +671,45 @@ class FormRegistrationController extends GetxController {
extractedName = idCardController.ktaModel.value?.name ?? ''; extractedName = idCardController.ktaModel.value?.name ?? '';
} }
} }
// Submit the entire form
Future<bool> submitForm() async {
if (!validateCurrentStep()) {
print('Form validation failed for step ${currentStep.value}');
return false;
}
if (currentStep.value < totalSteps - 1) {
// Move to next step if we're not at the last step
nextStep();
return false;
}
// We're at the last step, submit the form
try {
isSubmitting.value = true;
submitMessage.value = 'Submitting your registration...';
// Save all registration data using the identity verification controller
final identityController = Get.find<IdentityVerificationController>();
final result = await identityController.saveRegistrationData();
if (result) {
isSubmitSuccess.value = true;
submitMessage.value = 'Registration completed successfully!';
} else {
isSubmitSuccess.value = false;
submitMessage.value = 'Registration failed. Please try again.';
}
return result;
} catch (e) {
print('Error submitting form: $e');
isSubmitSuccess.value = false;
submitMessage.value = 'Error during registration: $e';
return false;
} finally {
isSubmitting.value = false;
}
}
} }

View File

@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:logger/logger.dart';
import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart';
import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/popups/loaders.dart';
class SignInController extends GetxController {
static SignInController get instance => Get.find();
final _logger = Logger();
final _authRepo = Get.find<AuthenticationRepository>();
// Form controllers
final email = TextEditingController();
final password = TextEditingController();
// Form error messages
final RxString emailError = RxString('');
final RxString passwordError = RxString('');
// States
final RxBool isLoading = RxBool(false);
final RxBool isPasswordVisible = RxBool(false);
@override
void onClose() {
email.dispose();
password.dispose();
super.onClose();
}
// Toggle password visibility
void togglePasswordVisibility() {
isPasswordVisible.value = !isPasswordVisible.value;
}
// Navigate to forgot password screen
void goToForgotPassword() {
Get.toNamed(AppRoutes.forgotPassword);
}
// Navigate to sign up screen
void goToSignUp() {
Get.toNamed(AppRoutes.signUp);
}
// Clear error messages
void clearErrors() {
emailError.value = '';
passwordError.value = '';
}
// Sign in with email and password
Future<void> signIn(GlobalKey<FormState> formKey) async {
// Clear previous errors
clearErrors();
// Validate form
final isValid = formKey.currentState?.validate() ?? false;
if (!isValid) return;
try {
isLoading.value = true;
// Attempt to sign in
final signInResult = await _authRepo.loginWithEmailPassword(
email: email.text.trim(),
password: password.text.trim(),
);
// Handle result
_logger.i('Sign in successful: $signInResult');
// Redirect based on user's profile status
_authRepo.screenRedirect();
} catch (e) {
isLoading.value = false;
// Handle specific errors
if (e.toString().contains('user-not-found')) {
emailError.value = 'No user found with this email';
} else if (e.toString().contains('wrong-password')) {
passwordError.value = 'Incorrect password';
} else if (e.toString().contains('invalid-email')) {
emailError.value = 'Invalid email format';
} else {
// Show general error
TLoaders.errorSnackBar(title: 'Sign In Failed', message: e.toString());
}
_logger.e('Sign in error: $e');
} finally {
isLoading.value = false;
}
}
// Sign in with Google
Future<void> googleSignIn() async {
try {
isLoading.value = true;
// Attempt to sign in with Google
await _authRepo.signInWithGoogle();
// Redirect based on user's profile status
_authRepo.screenRedirect();
} catch (e) {
isLoading.value = false;
// Show error
TLoaders.errorSnackBar(
title: 'Google Sign In Failed',
message: e.toString(),
);
_logger.e('Google sign in error: $e');
} finally {
isLoading.value = false;
}
}
}

View File

@ -1,183 +0,0 @@
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;
}
}
}

View File

@ -1,23 +1,21 @@
import 'dart:convert';
import 'dart:io'; 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:shared_preferences/shared_preferences.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';
class IdCardVerificationController extends GetxController { class IdCardVerificationController extends GetxController {
// Singleton instance // Singleton instance
static IdCardVerificationController get instance => Get.find(); static IdCardVerificationController get instance => Get.find();
// Services // Services
final AzureOCRService _ocrService = AzureOCRService(); final AzureOCRService _ocrService = AzureOCRService();
// Using FacialVerificationService instead of direct EdgeFunction
final FacialVerificationService _faceService =
FacialVerificationService.instance;
final bool isOfficer; final bool isOfficer;
// Maximum allowed file size in bytes (4MB) // Maximum allowed file size in bytes (4MB)
@ -25,6 +23,11 @@ class IdCardVerificationController extends GetxController {
IdCardVerificationController({required this.isOfficer}); IdCardVerificationController({required this.isOfficer});
// Local storage keys
static const String _kOcrResultsKey = 'ocr_results';
static const String _kOcrModelKey = 'ocr_model';
static const String _kIdCardTypeKey = 'id_card_type';
// ID Card variables // ID Card variables
final Rx<XFile?> idCardImage = Rx<XFile?>(null); final Rx<XFile?> idCardImage = Rx<XFile?>(null);
final RxString idCardError = RxString(''); final RxString idCardError = RxString('');
@ -46,7 +49,7 @@ class IdCardVerificationController extends GetxController {
// Add model variables for the extracted data // Add model variables for the extracted data
final Rx<KtpModel?> ktpModel = Rx<KtpModel?>(null); final Rx<KtpModel?> ktpModel = Rx<KtpModel?>(null);
final Rx<KtaModel?> ktaModel = Rx<KtaModel?>(null); final Rx<KtaModel?> ktaModel = Rx<KtaModel?>(null);
// Use FaceModel to store face details from ID card // Use FaceModel to store face details from ID card
final Rx<FaceModel> idCardFace = Rx<FaceModel>(FaceModel.empty()); final Rx<FaceModel> idCardFace = Rx<FaceModel>(FaceModel.empty());
@ -54,6 +57,51 @@ class IdCardVerificationController extends GetxController {
final RxString idCardFaceId = RxString(''); final RxString idCardFaceId = RxString('');
final RxBool hasFaceDetected = RxBool(false); final RxBool hasFaceDetected = RxBool(false);
// Save OCR results to local storage
Future<void> _saveOcrResultsToLocalStorage(
Map<String, String> results,
) async {
try {
final prefs = await SharedPreferences.getInstance();
final String jsonData = jsonEncode(results);
await prefs.setString(_kOcrResultsKey, jsonData);
await prefs.setString(_kIdCardTypeKey, isOfficer ? 'KTA' : 'KTP');
// Also save the model
if (isOfficer && ktaModel.value != null) {
await prefs.setString(
_kOcrModelKey,
jsonEncode(ktaModel.value!.toJson()),
);
} else if (!isOfficer && ktpModel.value != null) {
await prefs.setString(
_kOcrModelKey,
jsonEncode(ktpModel.value!.toJson()),
);
}
print('OCR results saved to local storage: ${results.length} items');
print('ID Card Type saved: ${isOfficer ? 'KTA' : 'KTP'}');
} catch (e) {
print('Error saving OCR results to local storage: $e');
}
}
// Load OCR results from local storage
Future<Map<String, String>> loadOcrResultsFromLocalStorage() async {
try {
final prefs = await SharedPreferences.getInstance();
final String? jsonData = prefs.getString(_kOcrResultsKey);
if (jsonData != null) {
final Map<String, dynamic> decodedData = jsonDecode(jsonData);
return decodedData.map((key, value) => MapEntry(key, value.toString()));
}
} catch (e) {
print('Error loading OCR results from local storage: $e');
}
return {};
}
bool validate() { bool validate() {
clearErrors(); clearErrors();
@ -131,7 +179,7 @@ class IdCardVerificationController extends GetxController {
ktpModel.value = null; ktpModel.value = null;
ktaModel.value = null; ktaModel.value = null;
// Reset face detection data // Initialize face data with empty model (just to maintain compatibility)
idCardFace.value = FaceModel.empty(); idCardFace.value = FaceModel.empty();
idCardFaceId.value = ''; idCardFaceId.value = '';
hasFaceDetected.value = false; hasFaceDetected.value = false;
@ -160,6 +208,12 @@ class IdCardVerificationController extends GetxController {
extractedInfo.assignAll(result); extractedInfo.assignAll(result);
hasExtractedInfo.value = result.isNotEmpty; hasExtractedInfo.value = result.isNotEmpty;
// Save the OCR results to local storage
if (result.isNotEmpty) {
print('Saving OCR results to local storage...');
await _saveOcrResultsToLocalStorage(result);
}
// Check if the extracted information is valid using our validation methods // Check if the extracted information is valid using our validation methods
if (isOfficer) { if (isOfficer) {
isImageValid = _ocrService.isKtaValid(result); isImageValid = _ocrService.isKtaValid(result);
@ -174,44 +228,8 @@ class IdCardVerificationController extends GetxController {
ktpModel.value = _ocrService.createKtpModel(result); ktpModel.value = _ocrService.createKtpModel(result);
} }
// Try to detect faces in the ID card image using FacialVerificationService
if (isImageValid) { if (isImageValid) {
try { // Instead of detecting faces here, we just save the image reference for later comparison
// 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!);
if (faces.isNotEmpty) {
// Store the face model
idCardFace.value = faces.first;
// For backward compatibility
idCardFaceId.value = faces.first.faceId;
hasFaceDetected.value = idCardFace.value.hasValidFace;
print('Face detected in ID card: ${idCardFace.value.faceId}');
}
}
} catch (faceError) {
print('Face detection failed: $faceError');
// Don't fail validation if face detection fails
}
isIdCardValid.value = true; isIdCardValid.value = true;
idCardValidationMessage.value = idCardValidationMessage.value =
'$idCardType image looks valid. Please confirm this is your $idCardType.'; '$idCardType image looks valid. Please confirm this is your $idCardType.';
@ -260,12 +278,12 @@ class IdCardVerificationController extends GetxController {
// Get the ID card image path for face comparison // Get the ID card image path for face comparison
String? get idCardImagePath => idCardImage.value?.path; String? get idCardImagePath => idCardImage.value?.path;
// Check if the ID card has a detected face // Check if the ID card has a detected face
bool get hasDetectedFace => idCardFace.value.hasValidFace; bool get hasDetectedFace => idCardFace.value.hasValidFace;
// Clear ID Card Image // Clear ID Card Image
void clearIdCardImage() { void clearIdCardImage() async {
idCardImage.value = null; idCardImage.value = null;
idCardError.value = ''; idCardError.value = '';
isIdCardValid.value = false; isIdCardValid.value = false;
@ -278,12 +296,34 @@ class IdCardVerificationController extends GetxController {
idCardFace.value = FaceModel.empty(); idCardFace.value = FaceModel.empty();
idCardFaceId.value = ''; idCardFaceId.value = '';
hasFaceDetected.value = false; hasFaceDetected.value = false;
// Also clear local storage
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kOcrResultsKey);
await prefs.remove(_kOcrModelKey);
await prefs.remove(_kIdCardTypeKey);
} catch (e) {
print('Error clearing OCR results from local storage: $e');
}
} }
// Confirm ID Card Image // Confirm ID Card Image
void confirmIdCardImage() { void confirmIdCardImage() {
if (isIdCardValid.value) { if (isIdCardValid.value) {
hasConfirmedIdCard.value = true; hasConfirmedIdCard.value = true;
// Log storage data for debugging
SharedPreferences.getInstance().then((prefs) {
print('Storage check on confirmation:');
print(
'OCR results: ${prefs.getString(_kOcrResultsKey)?.substring(0, 50)}...',
);
print(
'OCR model: ${prefs.getString(_kOcrModelKey)?.substring(0, 50)}...',
);
print('ID card type: ${prefs.getString(_kIdCardTypeKey)}');
});
} }
} }

View File

@ -1,14 +1,18 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.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/presentasion/controllers/selfie-verification/facial_verification_controller.dart';
// Remove AWS rekognition import completely import 'package:sigap/src/features/auth/data/bindings/registration_binding.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';
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart'; import 'package:sigap/src/features/auth/data/services/registration_service.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/basic/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/id-card-verification/id_card_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/selfie_verification_controller.dart';
class IdentityVerificationController extends GetxController { class IdentityVerificationController extends GetxController {
// Singleton instance // Singleton instance
@ -21,6 +25,11 @@ class IdentityVerificationController extends GetxController {
final FacialVerificationService _faceService = final FacialVerificationService _faceService =
FacialVerificationService.instance; FacialVerificationService.instance;
// Local storage keys (matching those in IdCardVerificationController)
static const String _kOcrResultsKey = 'ocr_results';
static const String _kOcrModelKey = 'ocr_model';
static const String _kIdCardTypeKey = 'id_card_type';
// Controllers // Controllers
final TextEditingController nikController = TextEditingController(); final TextEditingController nikController = TextEditingController();
final TextEditingController fullNameController = TextEditingController(); final TextEditingController fullNameController = TextEditingController();
@ -59,11 +68,22 @@ class IdentityVerificationController extends GetxController {
// Flag to prevent infinite loop // Flag to prevent infinite loop
bool _isApplyingData = false; bool _isApplyingData = false;
// NIK field readonly status
final RxBool isNikReadOnly = RxBool(false);
// Properties to store extracted ID card data // Properties to store extracted ID card data
final String? extractedIdCardNumber; final String? extractedIdCardNumber;
final String? extractedName; final String? extractedName;
final RxBool isPreFilledNik = false.obs; final RxBool isPreFilledNik = false.obs;
// Store the loaded OCR data
final RxMap<String, dynamic> ocrData = RxMap<String, dynamic>({});
// Status of data saving
final RxBool isSavingData = RxBool(false);
final RxBool isDataSaved = RxBool(false);
final RxString dataSaveMessage = RxString('');
IdentityVerificationController({ IdentityVerificationController({
this.extractedIdCardNumber = '', this.extractedIdCardNumber = '',
this.extractedName = '', this.extractedName = '',
@ -76,14 +96,134 @@ class IdentityVerificationController extends GetxController {
// Make sure selectedGender has a default value // Make sure selectedGender has a default value
selectedGender.value = selectedGender.value ?? 'Male'; selectedGender.value = selectedGender.value ?? 'Male';
// Try to apply ID card data after initialization // Load OCR data from local storage with debug info
Future.microtask(() => _safeApplyIdCardData()); print(
'Initializing IdentityVerificationController and loading OCR data...',
);
loadOcrDataFromLocalStorage();
} }
// Safely apply ID card data without risking stack overflow // Load OCR data from local storage
Future<void> loadOcrDataFromLocalStorage() async {
try {
final prefs = await SharedPreferences.getInstance();
// Load stored ID card type to verify it matches current flow
final String? storedIdCardType = prefs.getString(_kIdCardTypeKey);
print(
'Stored ID card type: $storedIdCardType, Current isOfficer: $isOfficer',
);
if (storedIdCardType == null ||
(isOfficer && storedIdCardType != 'KTA') ||
(!isOfficer && storedIdCardType != 'KTP')) {
print('No matching ID card data found in storage or type mismatch');
return;
}
// Load OCR results
final String? jsonData = prefs.getString(_kOcrResultsKey);
if (jsonData != null) {
print('Found OCR data in storage: ${jsonData.length} chars');
final Map<String, dynamic> results = jsonDecode(jsonData);
ocrData.assignAll(results);
print('OCR data loaded: ${results.length} items');
// Load OCR model
final String? modelJson = prefs.getString(_kOcrModelKey);
if (modelJson != null) {
print('Found OCR model in storage: ${modelJson.length} chars');
try {
if (isOfficer) {
final ktaModel = KtaModel.fromJson(jsonDecode(modelJson));
print('KTA model loaded successfully');
applyKtaDataToForm(ktaModel);
} else {
final ktpModel = KtpModel.fromJson(jsonDecode(modelJson));
print('KTP model loaded successfully - NIK: ${ktpModel.nik}');
applyKtpDataToForm(ktpModel);
}
isNikReadOnly.value = true;
print('NIK field set to read-only');
} catch (e) {
print('Error parsing model JSON: $e');
}
}
} else {
print('No OCR data found in storage');
}
} catch (e) {
print('Error loading OCR data from local storage: $e');
} finally {
// If data wasn't loaded from local storage, try from FormRegistrationController
if (ocrData.isEmpty) {
print('Falling back to FormRegistrationController data');
_safeApplyIdCardData();
}
}
}
// Apply KTP data to form
void applyKtpDataToForm(KtpModel ktpModel) {
if (ktpModel.nik.isNotEmpty) {
nikController.text = ktpModel.nik;
}
if (ktpModel.name.isNotEmpty) {
fullNameController.text = ktpModel.name;
}
if (ktpModel.birthPlace.isNotEmpty) {
placeOfBirthController.text = ktpModel.birthPlace;
}
if (ktpModel.birthDate.isNotEmpty) {
birthDateController.text = ktpModel.birthDate;
}
if (ktpModel.gender.isNotEmpty) {
// Convert gender to the format expected by the dropdown
String gender = ktpModel.gender.toLowerCase();
if (gender.contains('laki') || gender == 'male') {
selectedGender.value = 'Male';
} else if (gender.contains('perempuan') || gender == 'female') {
selectedGender.value = 'Female';
}
}
if (ktpModel.address.isNotEmpty) {
addressController.text = ktpModel.address;
}
// Mark as verified since we have validated KTP data
isVerified.value = true;
verificationMessage.value = 'KTP information loaded successfully';
}
// Apply KTA data to form
void applyKtaDataToForm(KtaModel ktaModel) {
// For officer, we'd fill in different fields as needed
if (ktaModel.name.isNotEmpty) {
fullNameController.text = ktaModel.name;
}
// If birthDate is available in extra data
if (ktaModel.extraData != null &&
ktaModel.extraData!.containsKey('tanggal_lahir') &&
ktaModel.extraData!['tanggal_lahir'] != null) {
birthDateController.text = ktaModel.extraData!['tanggal_lahir'];
}
// Mark as verified
isVerified.value = true;
verificationMessage.value = 'KTA information loaded successfully';
}
// Safely apply ID card data without risking stack overflow (fallback method)
void _safeApplyIdCardData() { void _safeApplyIdCardData() {
if (_isApplyingData) return; // Guard against recursive calls if (_isApplyingData) return; // Guard against recursive calls
try { try {
_isApplyingData = true; _isApplyingData = true;
@ -91,70 +231,21 @@ class IdentityVerificationController extends GetxController {
if (!Get.isRegistered<FormRegistrationController>()) { if (!Get.isRegistered<FormRegistrationController>()) {
return; return;
} }
final formController = Get.find<FormRegistrationController>(); final formController = Get.find<FormRegistrationController>();
if (formController.idCardData.value == null) { if (formController.idCardData.value == null) {
return; return;
} }
final idCardData = formController.idCardData.value; final idCardData = formController.idCardData.value;
if (idCardData != null) { if (idCardData != null) {
// Fill the form with the extracted data // Fill the form with the extracted data
if (!isOfficer && idCardData is KtpModel) { if (!isOfficer && idCardData is KtpModel) {
KtpModel ktpModel = idCardData; applyKtpDataToForm(idCardData);
isNikReadOnly.value = true;
if (ktpModel.nik.isNotEmpty) {
nikController.text = ktpModel.nik;
}
if (ktpModel.name.isNotEmpty) {
fullNameController.text = ktpModel.name;
}
if (ktpModel.birthPlace.isNotEmpty) {
placeOfBirthController.text = ktpModel.birthPlace;
}
if (ktpModel.birthDate.isNotEmpty) {
birthDateController.text = ktpModel.birthDate;
}
if (ktpModel.gender.isNotEmpty) {
// Convert gender to the format expected by the dropdown
String gender = ktpModel.gender.toLowerCase();
if (gender.contains('laki') || gender == 'male') {
selectedGender.value = 'Male';
} else if (gender.contains('perempuan') || gender == 'female') {
selectedGender.value = 'Female';
}
}
if (ktpModel.address.isNotEmpty) {
addressController.text = ktpModel.address;
}
// Mark as verified since we have validated KTP data
isVerified.value = true;
verificationMessage.value = 'KTP information verified successfully';
} else if (isOfficer && idCardData is KtaModel) { } else if (isOfficer && idCardData is KtaModel) {
KtaModel ktaModel = idCardData; applyKtaDataToForm(idCardData);
// For officer, we'd fill in different fields as needed
if (ktaModel.name.isNotEmpty) {
fullNameController.text = ktaModel.name;
}
// If birthDate is available in extra data
if (ktaModel.extraData != null &&
ktaModel.extraData!.containsKey('tanggal_lahir') &&
ktaModel.extraData!['tanggal_lahir'] != null) {
birthDateController.text = ktaModel.extraData!['tanggal_lahir'];
}
// Mark as verified
isVerified.value = true;
verificationMessage.value = 'KTA information verified successfully';
} }
} }
} catch (e) { } catch (e) {
@ -195,10 +286,10 @@ class IdentityVerificationController extends GetxController {
isFormValid.value = false; 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;
} // }
return isFormValid.value; return isFormValid.value;
} }
@ -416,4 +507,38 @@ class IdentityVerificationController extends GetxController {
isPreFilledNik.value = true; isPreFilledNik.value = true;
} }
// Save registration data
Future<bool> saveRegistrationData() async {
try {
isSavingData.value = true;
dataSaveMessage.value = 'Saving your registration data...';
// Ensure the registration service is available();
if (!Get.isRegistered<RegistrationService>()) {
await Get.putAsync(() async => RegistrationService());
} // Get registration service
final registrationService = Get.find<RegistrationService>();
final result = await registrationService.saveRegistrationData();
if (result) {
isDataSaved.value = true;
dataSaveMessage.value = 'Registration data saved successfully!';
} else {
isDataSaved.value = false;
dataSaveMessage.value =
'Failed to save registration data. Please try again.';
}
return result;
} catch (e) {
isDataSaved.value = false;
dataSaveMessage.value = 'Error saving registration data: $e';
print('Error saving registration data: $e');
return false;
} finally {
isSavingData.value = false;
}
}
} }

View File

@ -0,0 +1,511 @@
import 'dart:io';
import 'dart:math' as Math;
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart';
import 'package:google_mlkit_face_mesh_detection/google_mlkit_face_mesh_detection.dart';
import 'package:sigap/src/features/auth/data/models/face_model.dart';
enum LivenessStatus {
preparing,
detectingFace,
checkLeftRotation,
checkRightRotation,
checkSmile,
checkEyesOpen,
readyForPhoto,
photoTaken,
completed,
failed,
}
class FaceLivenessController extends GetxController
with WidgetsBindingObserver {
// Camera
CameraController? _cameraController;
late FaceDetector _faceDetector;
var frontCamera;
// Face Detection States
final _isFaceInFrame = false.obs;
final _isFaceLeft = false.obs;
final _isFaceRight = false.obs;
final _isEyeOpen = false.obs;
final _isNoFace = false.obs;
final _isMultiFace = false.obs;
final _isCaptured = false.obs;
final _isSmiled = false.obs;
final _isFaceReadyForPhoto = false.obs;
final _isDifferentPerson = false.obs;
// Status tracking
final Rx<LivenessStatus> status = Rx<LivenessStatus>(
LivenessStatus.preparing,
);
final RxString currentInstruction = RxString('Initializing camera...');
// Getters
bool get isFaceInFrame => _isFaceInFrame.value;
bool get isFaceLeft => _isFaceLeft.value;
bool get isFaceRight => _isFaceRight.value;
bool get isEyeOpen => _isEyeOpen.value;
bool get isNoFace => _isNoFace.value;
bool get isMultiFace => _isMultiFace.value;
bool get isCaptured => _isCaptured.value;
bool get isSmiled => _isSmiled.value;
bool get isFaceReadyForPhoto => _isFaceReadyForPhoto.value;
bool get isDifferentPerson => _isDifferentPerson.value;
CameraController? get cameraController => _cameraController;
// Face Mesh Detector
final FaceMeshDetector _faceMeshDetector = FaceMeshDetector(
option: FaceMeshDetectorOptions.faceMesh,
);
// Face Comparison
List<double>? _firstPersonEmbedding;
// Captured Image
final _capturedImage = Rxn<XFile>();
XFile? get capturedImage => _capturedImage.value;
// Successful Steps
final _successfulSteps = <String>[].obs;
List<String> get successfulSteps => _successfulSteps;
// Face Detector Options
final FaceDetectorOptions options = FaceDetectorOptions(
performanceMode:
Platform.isAndroid ? FaceDetectorMode.fast : FaceDetectorMode.accurate,
enableClassification: true,
enableLandmarks: true,
enableTracking: true,
);
// Device Orientations
final orientations = {
DeviceOrientation.portraitUp: 0,
DeviceOrientation.landscapeLeft: 90,
DeviceOrientation.portraitDown: 180,
DeviceOrientation.landscapeRight: 270,
};
@override
void onInit() {
super.onInit();
WidgetsBinding.instance.addObserver(this);
_initializeCamera();
_faceDetector = FaceDetector(options: options);
}
Future<void> _initializeCamera() async {
try {
status.value = LivenessStatus.preparing;
currentInstruction.value = 'Initializing camera...';
final cameras = await availableCameras();
final frontCameras = cameras.firstWhere(
(camera) => camera.lensDirection == CameraLensDirection.front,
);
frontCamera = frontCameras;
_cameraController = CameraController(
frontCamera,
ResolutionPreset.medium,
imageFormatGroup:
Platform.isAndroid
? ImageFormatGroup.nv21
: ImageFormatGroup.bgra8888,
);
await _cameraController!.initialize();
_cameraController!.startImageStream((CameraImage img) {
_processCameraImage(img);
});
status.value = LivenessStatus.detectingFace;
currentInstruction.value = 'Position your face in the frame';
update(); // Notify GetX to rebuild UI
} catch (e) {
print('Error initializing camera: $e');
status.value = LivenessStatus.failed;
currentInstruction.value = 'Failed to initialize camera: $e';
}
}
Future<void> _processCameraImage(CameraImage img) async {
try {
final inputImage = _getInputImageFromCameraImage(img);
if (inputImage == null) return;
final List<Face> faces = await _faceDetector.processImage(inputImage);
if (faces.length > 1) {
_isMultiFace.value = true;
_successfulSteps.clear();
_resetFaceDetectionStatus();
status.value = LivenessStatus.detectingFace;
currentInstruction.value =
'Multiple faces detected. Please ensure only your face is visible.';
} else if (faces.isEmpty) {
_isNoFace.value = true;
_successfulSteps.clear();
_resetFaceDetectionStatus();
status.value = LivenessStatus.detectingFace;
currentInstruction.value =
'No face detected. Please position your face in the frame.';
} else if (faces.isNotEmpty) {
_isMultiFace.value = false;
_isNoFace.value = false;
final Face face = faces.first;
await _compareFaces(face);
if (_isDifferentPerson.value) {
_duplicatePersonFaceDetect();
return;
}
_handleFaceDetection(face);
} else {
_handleNoFaceDetected();
}
} catch (e) {
print('Error processing camera image: $e');
}
}
void _handleFaceDetection(Face face) {
if (!_isCaptured.value) {
final double? rotY = face.headEulerAngleY;
final double leftEyeOpen = face.leftEyeOpenProbability ?? -1.0;
final double rightEyeOpen = face.rightEyeOpenProbability ?? -1.0;
final double smileProb = face.smilingProbability ?? -1.0;
print("Head angle: $rotY");
print("Left eye open: $leftEyeOpen");
print("Right eye open: $rightEyeOpen");
print("Smiling probability: $smileProb");
_updateFaceInFrameStatus();
_updateHeadRotationStatus(rotY);
_updateSmilingStatus(smileProb);
_updateEyeOpenStatus(leftEyeOpen, rightEyeOpen);
_updateFaceInFrameForPhotoStatus(rotY, smileProb);
if (_isFaceInFrame.value &&
_isFaceLeft.value &&
_isFaceRight.value &&
_isSmiled.value &&
_isFaceReadyForPhoto.value &&
_isEyeOpen.value) {
if (!_isCaptured.value) {
_captureImage();
}
}
}
}
void _handleNoFaceDetected() {
if (_isFaceInFrame.value) {
_resetFaceDetectionStatus();
status.value = LivenessStatus.detectingFace;
currentInstruction.value =
'Face lost. Please position your face in the frame.';
}
}
void _duplicatePersonFaceDetect() {
if (_isDifferentPerson.value) {
_addSuccessfulStep('Different person Found');
_resetFaceDetectionStatus();
status.value = LivenessStatus.detectingFace;
currentInstruction.value =
'Different person detected. Please ensure only you are in the frame.';
}
}
void _updateFaceInFrameStatus() {
if (!_isFaceInFrame.value) {
_isFaceInFrame.value = true;
_addSuccessfulStep('Face in frame');
if (status.value == LivenessStatus.detectingFace) {
status.value = LivenessStatus.checkLeftRotation;
currentInstruction.value = 'Great! Now rotate your face to the left';
}
}
}
void _updateFaceInFrameForPhotoStatus(double? rotY, double? smileProb) {
if (_isFaceRight.value &&
_isFaceLeft.value &&
rotY != null &&
rotY > -2 &&
rotY < 2 &&
smileProb! < 0.2) {
_isFaceReadyForPhoto.value = true;
_addSuccessfulStep('Face Ready For Photo');
if (status.value == LivenessStatus.checkEyesOpen) {
status.value = LivenessStatus.readyForPhoto;
currentInstruction.value = 'Perfect! Hold still for photo capture';
}
} else {
_isFaceReadyForPhoto.value = false;
}
}
void _updateHeadRotationStatus(double? rotY) {
if (_isFaceInFrame.value &&
!_isFaceLeft.value &&
rotY != null &&
rotY < -7) {
_isFaceLeft.value = true;
_addSuccessfulStep('Face rotated left');
if (status.value == LivenessStatus.checkLeftRotation) {
status.value = LivenessStatus.checkRightRotation;
currentInstruction.value = 'Good! Now rotate your face to the right';
}
}
if (_isFaceLeft.value && !_isFaceRight.value && rotY != null && rotY > 7) {
_isFaceRight.value = true;
_addSuccessfulStep('Face rotated right');
if (status.value == LivenessStatus.checkRightRotation) {
status.value = LivenessStatus.checkSmile;
currentInstruction.value = 'Great! Now smile for the camera';
}
}
}
void _updateEyeOpenStatus(double leftEyeOpen, double rightEyeOpen) {
if (_isFaceInFrame.value &&
_isFaceLeft.value &&
_isFaceRight.value &&
_isSmiled.value &&
!_isEyeOpen.value) {
if (leftEyeOpen > 0.3 && rightEyeOpen > 0.3) {
_isEyeOpen.value = true;
_addSuccessfulStep('Eyes Open');
if (status.value == LivenessStatus.checkEyesOpen) {
status.value = LivenessStatus.readyForPhoto;
currentInstruction.value = 'Perfect! Hold still for photo capture';
}
}
}
}
void _updateSmilingStatus(double smileProb) {
if (_isFaceInFrame.value &&
_isFaceLeft.value &&
_isFaceRight.value &&
!_isSmiled.value &&
smileProb > 0.3) {
_isSmiled.value = true;
_addSuccessfulStep('Smiling');
if (status.value == LivenessStatus.checkSmile) {
status.value = LivenessStatus.checkEyesOpen;
currentInstruction.value = 'Excellent! Now open your eyes wide';
}
}
}
void _resetFaceDetectionStatus() {
_isFaceInFrame.value = false;
_isFaceLeft.value = false;
_isFaceRight.value = false;
_isEyeOpen.value = false;
_isNoFace.value = false;
_isMultiFace.value = false;
_isSmiled.value = false;
_successfulSteps.clear();
}
void resetProcess() {
_capturedImage.value = null;
_isCaptured.value = false;
_resetFaceDetectionStatus();
status.value = LivenessStatus.preparing;
currentInstruction.value = 'Resetting liveness check...';
// Reinitialize camera if needed
if (_cameraController == null || !_cameraController!.value.isInitialized) {
_initializeCamera();
} else {
status.value = LivenessStatus.detectingFace;
currentInstruction.value = 'Position your face in the frame';
}
}
void _addSuccessfulStep(String step) {
if (!_successfulSteps.contains(step)) {
_successfulSteps.add(step);
}
}
InputImage? _getInputImageFromCameraImage(CameraImage image) {
final sensorOrientation = frontCamera.sensorOrientation;
InputImageRotation? rotation;
if (Platform.isIOS) {
rotation = InputImageRotationValue.fromRawValue(sensorOrientation);
} else if (Platform.isAndroid) {
var rotationCompensation =
orientations[_cameraController!.value.deviceOrientation];
if (rotationCompensation == null) return null;
if (frontCamera.lensDirection == CameraLensDirection.front) {
rotationCompensation = (sensorOrientation + rotationCompensation) % 360;
} else {
rotationCompensation =
(sensorOrientation - rotationCompensation + 360) % 360;
}
rotation = InputImageRotationValue.fromRawValue(rotationCompensation!);
}
if (rotation == null) return null;
final format = InputImageFormatValue.fromRawValue(image.format.raw);
if (format == null ||
(Platform.isAndroid && format != InputImageFormat.nv21) ||
(Platform.isIOS && format != InputImageFormat.bgra8888))
return null;
if (image.planes.length != 1) return null;
final plane = image.planes.first;
return InputImage.fromBytes(
bytes: plane.bytes,
metadata: InputImageMetadata(
size: Size(image.width.toDouble(), image.height.toDouble()),
rotation: rotation,
format: format,
bytesPerRow: plane.bytesPerRow,
),
);
}
Future<void> _captureImage() async {
if (_cameraController!.value.isTakingPicture) return;
try {
status.value = LivenessStatus.photoTaken;
currentInstruction.value = 'Capturing photo...';
final XFile file = await _cameraController!.takePicture();
_isCaptured.value = true;
_capturedImage.value = file;
status.value = LivenessStatus.completed;
currentInstruction.value = 'Liveness check successful!';
_faceDetector.close();
} catch (e) {
print('Error capturing image: $e');
status.value = LivenessStatus.failed;
currentInstruction.value = 'Failed to capture image: $e';
}
}
// Face comparison methods
Future<List<double>> _extractFaceEmbeddings(Face face) async {
return [
face.boundingBox.left,
face.boundingBox.top,
face.boundingBox.right,
face.boundingBox.bottom,
];
}
Future<void> _compareFaces(Face currentFace) async {
final currentEmbedding = await _extractFaceEmbeddings(currentFace);
if (_firstPersonEmbedding == null) {
_firstPersonEmbedding = currentEmbedding;
} else {
final double similarity = _calculateSimilarity(
_firstPersonEmbedding!,
currentEmbedding,
);
_isDifferentPerson.value = similarity < 0.8;
}
}
double _calculateSimilarity(
List<double> embedding1,
List<double> embedding2,
) {
double dotProduct = 0.0;
double norm1 = 0.0;
double norm2 = 0.0;
for (int i = 0; i < embedding1.length; i++) {
dotProduct += embedding1[i] * embedding2[i];
norm1 += embedding1[i] * embedding1[i];
norm2 += embedding2[i] * embedding2[i];
}
return dotProduct / (Math.sqrt(norm1) * Math.sqrt(norm2));
}
String getCurrentDirection() {
// Use the currentInstruction instead
return currentInstruction.value;
}
bool _isFaceInsideFrame(Rect boundingBox) {
const double previewWidth = 300;
const double previewHeight = 300;
return boundingBox.left >= 0 &&
boundingBox.top >= 0 &&
boundingBox.right <= previewWidth &&
boundingBox.bottom <= previewHeight;
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final CameraController? cameraController = _cameraController;
if (cameraController == null || !cameraController.value.isInitialized) {
return;
}
if (state == AppLifecycleState.inactive) {
cameraController.dispose();
} else if (state == AppLifecycleState.resumed) {
_initializeCamera();
}
}
@override
void onClose() {
_faceDetector.close();
if (_cameraController != null) _cameraController!.dispose();
WidgetsBinding.instance.removeObserver(this);
_faceMeshDetector.close();
super.onClose();
}
/// Generate a FaceModel from the captured image
FaceModel generateFaceModel() {
if (_capturedImage.value == null) {
return FaceModel.empty();
}
return FaceModel(
imagePath: _capturedImage.value!.path,
faceId: 'live-face-${DateTime.now().millisecondsSinceEpoch}',
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 successfully',
);
}
}

View File

@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
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';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection.dart';
/// Service for handling facial verification
/// This class serves as a bridge between UI controllers and face detection functionality
class FacialVerificationService {
// Singleton pattern
static final FacialVerificationService _instance =
FacialVerificationService._();
static FacialVerificationService get instance => _instance;
// Private constructor
FacialVerificationService._();
// Edge Function Service for actual API calls
final EdgeFunctionService _edgeFunctionService = EdgeFunctionService.instance;
// Flag for skipping verification in development mode
final bool skipFaceVerification = false;
/// Get all faces in an image using edge function
Future<List<FaceModel>> detectFaces(XFile image) async {
if (skipFaceVerification) {
return [_createDummyFaceModel(image.path)];
}
return await _edgeFunctionService.detectFaces(image);
}
/// Detect if there is a face in the image
Future<bool> detectFaceInImage(XFile image) async {
if (skipFaceVerification) return true;
try {
final detectedFaces = await _edgeFunctionService.detectFaces(image);
return detectedFaces.isNotEmpty;
} catch (e) {
print('Error detecting face: $e');
return false;
}
}
/// Compare faces between two images using edge function
Future<FaceComparisonResult> compareFaces(XFile source, XFile target) async {
if (skipFaceVerification) {
return _createDummyComparisonResult(source.path, target.path);
}
return await _edgeFunctionService.compareFaces(source, target);
}
/// Start liveness check - this will navigate to the liveness check screen
/// and return a model with the captured image if successful
Future<FaceModel> startLivenessCheck(BuildContext context) async {
if (skipFaceVerification) {
// Mock the process for development mode
return _createDummyFaceModel("dummy_path", isLive: true);
}
// In a real implementation, this would navigate to the liveness check screen
// and return the result when complete.
// For now, we're just returning a placeholder model
final livenessController = Get.find<FaceLivenessController>();
// Wait for liveness check to complete
// This is a placeholder - in a real implementation we would wait for the
// liveness check screen to return a result
// Return placeholder model
return FaceModel.empty().withLiveness(
isLive: false,
confidence: 0.0,
message: 'Liveness check not implemented yet',
);
}
// Helper methods for development mode
FaceModel _createDummyFaceModel(String imagePath, {bool isLive = false}) {
return FaceModel(
imagePath: imagePath,
faceId: 'dummy-face-${DateTime.now().millisecondsSinceEpoch}',
confidence: 0.95,
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
).withLiveness(
isLive: isLive,
confidence: isLive ? 0.92 : 0.0,
message:
isLive
? 'Liveness check passed (development mode)'
: 'Basic face detection only (development mode)',
);
}
FaceComparisonResult _createDummyComparisonResult(
String sourcePath,
String targetPath,
) {
final sourceFace = _createDummyFaceModel(sourcePath);
final targetFace = _createDummyFaceModel(targetPath, isLive: true);
return FaceComparisonResult(
sourceFace: sourceFace,
targetFace: targetFace,
isMatch: true,
confidence: 0.91,
similarity: 91.0,
similarityThreshold: 75.0,
confidenceLevel: "HIGH",
message: 'Face matching successful (development mode)',
);
}
}

View File

@ -0,0 +1,498 @@
import 'dart:io' as i;
import 'dart:io';
import 'dart:math' as Math;
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart';
import 'package:google_mlkit_face_mesh_detection/google_mlkit_face_mesh_detection.dart';
import 'package:sigap/src/features/auth/data/models/face_model.dart';
class FaceLivenessController extends GetxController
with WidgetsBindingObserver {
// Camera
CameraController? _cameraController;
late FaceDetector _faceDetector;
var frontCamera;
// Face Detection States
final _isFaceInFrame = false.obs;
final _isFaceLeft = false.obs;
final _isFaceRight = false.obs;
final _isEyeOpen = false.obs;
final _isNoFace = false.obs;
final _isMultiFace = false.obs;
final _isCaptured = false.obs;
final _isSmiled = false.obs;
final _isFaceReadyForPhoto = false.obs;
final _isDifferentPerson = false.obs;
// Getters
bool get isFaceInFrame => _isFaceInFrame.value;
bool get isFaceLeft => _isFaceLeft.value;
bool get isFaceRight => _isFaceRight.value;
bool get isEyeOpen => _isEyeOpen.value;
bool get isNoFace => _isNoFace.value;
bool get isMultiFace => _isMultiFace.value;
bool get isCaptured => _isCaptured.value;
bool get isSmiled => _isSmiled.value;
bool get isFaceReadyForPhoto => _isFaceReadyForPhoto.value;
bool get isDifferentPerson => _isDifferentPerson.value;
CameraController? get cameraController => _cameraController;
// Face Mesh Detector
final FaceMeshDetector _faceMeshDetector = FaceMeshDetector(
option: FaceMeshDetectorOptions.faceMesh,
);
// Face Comparison
List<double>? _firstPersonEmbedding;
// Captured Image
final _capturedImage = Rxn<XFile>();
XFile? get capturedImage => _capturedImage.value;
// Successful Steps
final _successfulSteps = <String>[].obs;
List<String> get successfulSteps => _successfulSteps;
// Face Detector Options
final FaceDetectorOptions options = FaceDetectorOptions(
performanceMode:
Platform.isAndroid ? FaceDetectorMode.fast : FaceDetectorMode.accurate,
enableClassification: true,
enableLandmarks: true,
enableTracking: true,
);
// Device Orientations
final orientations = {
DeviceOrientation.portraitUp: 0,
DeviceOrientation.landscapeLeft: 90,
DeviceOrientation.portraitDown: 180,
DeviceOrientation.landscapeRight: 270,
};
@override
void onInit() {
super.onInit();
WidgetsBinding.instance.addObserver(this);
_initializeCamera();
_faceDetector = FaceDetector(options: options);
}
Future<void> _initializeCamera() async {
try {
final cameras = await availableCameras();
final frontCameras = cameras.firstWhere(
(camera) => camera.lensDirection == CameraLensDirection.front,
);
frontCamera = frontCameras;
_cameraController = CameraController(
frontCamera,
ResolutionPreset.medium,
imageFormatGroup:
Platform.isAndroid
? ImageFormatGroup.nv21
: ImageFormatGroup.bgra8888,
);
await _cameraController!.initialize();
_cameraController!.startImageStream((CameraImage img) {
_processCameraImage(img);
});
update(); // Notify GetX to rebuild UI
} catch (e) {
print('Error initializing camera: $e');
}
}
Future<void> _processCameraImage(CameraImage img) async {
try {
final inputImage = _getInputImageFromCameraImage(img);
if (inputImage == null) return;
final List<Face> faces = await _faceDetector.processImage(inputImage);
if (faces.length > 1) {
_isMultiFace.value = true;
_successfulSteps.clear();
_resetFaceDetectionStatus();
} else if (faces.isEmpty) {
_isNoFace.value = true;
_successfulSteps.clear();
_resetFaceDetectionStatus();
} else if (faces.isNotEmpty) {
_isMultiFace.value = false;
_isNoFace.value = false;
final Face face = faces.first;
await _compareFaces(face);
if (_isDifferentPerson.value) {
_duplicatePersonFaceDetect();
return;
}
_handleFaceDetection(face);
} else {
_handleNoFaceDetected();
}
} catch (e) {
print('Error processing camera image: $e');
}
}
void _handleFaceDetection(Face face) {
if (!_isCaptured.value) {
final double? rotY = face.headEulerAngleY;
final double leftEyeOpen = face.leftEyeOpenProbability ?? -1.0;
final double rightEyeOpen = face.rightEyeOpenProbability ?? -1.0;
final double smileProb = face.smilingProbability ?? -1.0;
print("Head angle: $rotY");
print("Left eye open: $leftEyeOpen");
print("Right eye open: $rightEyeOpen");
print("Smiling probability: $smileProb");
_updateFaceInFrameStatus();
_updateHeadRotationStatus(rotY);
_updateSmilingStatus(smileProb);
_updateEyeOpenStatus(leftEyeOpen, rightEyeOpen);
_updateFaceInFrameForPhotoStatus(rotY, smileProb);
if (_isFaceInFrame.value &&
_isFaceLeft.value &&
_isFaceRight.value &&
_isSmiled.value &&
_isFaceReadyForPhoto.value &&
_isEyeOpen.value) {
if (!_isCaptured.value) {
_captureImage();
}
}
}
}
void _handleNoFaceDetected() {
if (_isFaceInFrame.value) {
_resetFaceDetectionStatus();
}
}
void _duplicatePersonFaceDetect() {
if (_isDifferentPerson.value) {
_addSuccessfulStep('Different person Found');
_resetFaceDetectionStatus();
}
}
void _updateFaceInFrameStatus() {
if (!_isFaceInFrame.value) {
_isFaceInFrame.value = true;
_addSuccessfulStep('Face in frame');
}
}
void _updateFaceInFrameForPhotoStatus(double? rotY, double? smileProb) {
if (_isFaceRight.value &&
_isFaceLeft.value &&
rotY != null &&
rotY > -2 &&
rotY < 2 &&
smileProb! < 0.2) {
_isFaceReadyForPhoto.value = true;
_addSuccessfulStep('Face Ready For Photo');
} else {
_isFaceReadyForPhoto.value = false;
}
}
void _updateHeadRotationStatus(double? rotY) {
if (_isFaceInFrame.value &&
!_isFaceLeft.value &&
rotY != null &&
rotY < -7) {
_isFaceLeft.value = true;
_addSuccessfulStep('Face rotated left');
}
if (_isFaceLeft.value && !_isFaceRight.value && rotY != null && rotY > 7) {
_isFaceRight.value = true;
_addSuccessfulStep('Face rotated right');
}
}
void _updateEyeOpenStatus(double leftEyeOpen, double rightEyeOpen) {
if (_isFaceInFrame.value &&
_isFaceLeft.value &&
_isFaceRight.value &&
_isSmiled.value &&
!_isEyeOpen.value) {
if (leftEyeOpen > 0.3 && rightEyeOpen > 0.3) {
_isEyeOpen.value = true;
_addSuccessfulStep('Eyes Open');
}
}
}
void _updateSmilingStatus(double smileProb) {
if (_isFaceInFrame.value &&
_isFaceLeft.value &&
_isFaceRight.value &&
!_isSmiled.value &&
smileProb > 0.3) {
_isSmiled.value = true;
_addSuccessfulStep('Smiling');
}
}
void _resetFaceDetectionStatus() {
_isFaceInFrame.value = false;
_isFaceLeft.value = false;
_isFaceRight.value = false;
_isEyeOpen.value = false;
_isNoFace.value = false;
_isMultiFace.value = false;
_isSmiled.value = false;
_successfulSteps.clear();
}
void _addSuccessfulStep(String step) {
if (!_successfulSteps.contains(step)) {
_successfulSteps.add(step);
}
}
InputImage? _getInputImageFromCameraImage(CameraImage image) {
final sensorOrientation = frontCamera.sensorOrientation;
InputImageRotation? rotation;
if (Platform.isIOS) {
rotation = InputImageRotationValue.fromRawValue(sensorOrientation);
} else if (Platform.isAndroid) {
var rotationCompensation =
orientations[_cameraController!.value.deviceOrientation];
if (rotationCompensation == null) return null;
if (frontCamera.lensDirection == CameraLensDirection.front) {
rotationCompensation = (sensorOrientation + rotationCompensation) % 360;
} else {
rotationCompensation =
(sensorOrientation - rotationCompensation + 360) % 360;
}
rotation = InputImageRotationValue.fromRawValue(rotationCompensation!);
}
if (rotation == null) return null;
final format = InputImageFormatValue.fromRawValue(image.format.raw);
if (format == null ||
(Platform.isAndroid && format != InputImageFormat.nv21) ||
(Platform.isIOS && format != InputImageFormat.bgra8888))
return null;
if (image.planes.length != 1) return null;
final plane = image.planes.first;
return InputImage.fromBytes(
bytes: plane.bytes,
metadata: InputImageMetadata(
size: Size(image.width.toDouble(), image.height.toDouble()),
rotation: rotation,
format: format,
bytesPerRow: plane.bytesPerRow,
),
);
}
Future<void> _captureImage() async {
if (_cameraController!.value.isTakingPicture) return;
try {
final XFile file = await _cameraController!.takePicture();
_isCaptured.value = true;
_capturedImage.value = file;
final bytes = i.File(file.path).readAsBytesSync();
_faceDetector.close();
} catch (e) {
print('Error capturing image: $e');
}
}
// Face comparison methods
Future<List<double>> _extractFaceEmbeddings(Face face) async {
return [
face.boundingBox.left,
face.boundingBox.top,
face.boundingBox.right,
face.boundingBox.bottom,
];
}
Future<void> _compareFaces(Face currentFace) async {
final currentEmbedding = await _extractFaceEmbeddings(currentFace);
if (_firstPersonEmbedding == null) {
_firstPersonEmbedding = currentEmbedding;
} else {
final double similarity = _calculateSimilarity(
_firstPersonEmbedding!,
currentEmbedding,
);
_isDifferentPerson.value = similarity < 0.8;
}
}
double _calculateSimilarity(
List<double> embedding1,
List<double> embedding2,
) {
double dotProduct = 0.0;
double norm1 = 0.0;
double norm2 = 0.0;
for (int i = 0; i < embedding1.length; i++) {
dotProduct += embedding1[i] * embedding2[i];
norm1 += embedding1[i] * embedding1[i];
norm2 += embedding2[i] * embedding2[i];
}
return dotProduct / (Math.sqrt(norm1) * Math.sqrt(norm2));
}
String getCurrentDirection() {
if (!_isFaceInFrame.value) {
return 'Enter your face in the frame';
} else if (_isNoFace.value) {
return 'No Faces in Camera';
} else if (_isMultiFace.value) {
return 'Multi Faces in Camera';
} else if (!_isFaceLeft.value) {
return 'Rotate your face to the left (10° & 5 Sec)';
} else if (!_isFaceRight.value) {
return 'Rotate your face to the right (10° & 5 Sec)';
} else if (!_isSmiled.value) {
return 'Keep One Smile ';
} else if (!_isEyeOpen.value) {
return 'Open Your Eyes';
} else if (!_isFaceReadyForPhoto.value) {
return 'Ready For capture Photo, don\'t laughing and keep strait your photo';
} else {
return 'Liveness detected! Image captured.';
}
}
bool _isFaceInsideFrame(Rect boundingBox) {
const double previewWidth = 300;
const double previewHeight = 300;
return boundingBox.left >= 0 &&
boundingBox.top >= 0 &&
boundingBox.right <= previewWidth &&
boundingBox.bottom <= previewHeight;
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final CameraController? cameraController = _cameraController;
if (cameraController == null || !cameraController.value.isInitialized) {
return;
}
if (state == AppLifecycleState.inactive) {
cameraController.dispose();
} else if (state == AppLifecycleState.resumed) {
_initializeCamera();
}
}
@override
void onClose() {
_faceDetector.close();
if (_cameraController != null) _cameraController!.dispose();
WidgetsBinding.instance.removeObserver(this);
_faceMeshDetector.close();
super.onClose();
}
/// Generate a FaceModel from the captured image
FaceModel generateFaceModel() {
if (_capturedImage.value == null) {
return FaceModel.empty();
}
return FaceModel(
imagePath: _capturedImage.value!.path,
faceId: 'live-face-${DateTime.now().millisecondsSinceEpoch}',
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 successfully',
);
}
/// Compare faces between two images
Future<FaceComparisonResult> compareFaces(
XFile source,
XFile target, {
bool skipVerification = false,
}) async {
if (skipVerification) {
// Return dummy successful result for development
final sourceFace = FaceModel(
imagePath: source.path,
faceId: 'source-${DateTime.now().millisecondsSinceEpoch}',
confidence: 0.95,
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
);
final targetFace = FaceModel(
imagePath: target.path,
faceId: 'target-${DateTime.now().millisecondsSinceEpoch}',
confidence: 0.95,
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
);
return FaceComparisonResult(
sourceFace: sourceFace,
targetFace: targetFace,
isMatch: true,
confidence: 0.91,
message: 'Face matching successful (development mode)',
);
}
// In real implementation, this would call a backend service
// For now, simulate a match with random confidence
final confidence = 0.85 + (DateTime.now().millisecond % 10) / 100;
final isMatch = confidence > 0.85;
final sourceFace = FaceModel(
imagePath: source.path,
faceId: 'source-${DateTime.now().millisecondsSinceEpoch}',
confidence: 0.9,
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
);
final targetFace = FaceModel(
imagePath: target.path,
faceId: 'target-${DateTime.now().millisecondsSinceEpoch}',
confidence: 0.9,
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
);
return FaceComparisonResult(
sourceFace: sourceFace,
targetFace: targetFace,
isMatch: isMatch,
confidence: confidence,
message:
isMatch
? 'Face matching successful with ${(confidence * 100).toStringAsFixed(1)}% confidence'
: 'Face matching failed. The faces do not appear to be the same person.',
);
}
}

View File

@ -0,0 +1,414 @@
import 'dart:io';
import 'package:get/get.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/presentasion/controllers/id-card-verification/id_card_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/facial_verification_controller.dart';
class SelfieVerificationController extends GetxController {
// MARK: - Dependencies
final FacialVerificationService _facialVerificationService =
FacialVerificationService.instance;
late FaceLivenessController _livenessController;
// MARK: - Constants
final int maxFileSizeBytes = 4 * 1024 * 1024; // 4MB
// Form validation
final RxBool isFormValid = RxBool(true);
final RxString selfieError = RxString('');
final RxString selfieValidationMessage = RxString('');
// Image state
final Rx<XFile?> selfieImage = Rx<XFile?>(null);
final Rx<FaceModel> selfieFace = Rx<FaceModel>(FaceModel.empty());
// Process state flags
final RxBool isVerifyingFace = RxBool(false);
final RxBool isUploadingSelfie = RxBool(false);
final RxBool isPerformingLivenessCheck = RxBool(false);
final RxBool isComparingWithIDCard = RxBool(false);
// Verification results
final RxBool isSelfieValid = RxBool(false);
final RxBool isLivenessCheckPassed = RxBool(false);
final RxBool hasConfirmedSelfie = RxBool(false);
// Face comparison results
final Rx<FaceComparisonResult?> faceComparisonResult =
Rx<FaceComparisonResult?>(null);
final RxBool isMatchWithIDCard = RxBool(false);
final RxDouble matchConfidence = RxDouble(0.0);
final RxString selfieImageFaceId = RxString('');
@override
void onInit() {
super.onInit();
_livenessController = Get.put(FaceLivenessController());
// Listen to liveness detection completion
ever(_livenessController.status, (LivenessStatus status) {
if (status == LivenessStatus.completed &&
_livenessController.capturedImage != null) {
// When liveness check completes successfully, update selfie data
_processCapturedLivenessImage();
}
});
}
// Process the image captured during liveness detection
Future<void> _processCapturedLivenessImage() async {
if (_livenessController.capturedImage == null) return;
try {
// Update selfie data
selfieImage.value = _livenessController.capturedImage;
// Generate face model from liveness controller
selfieFace.value = _livenessController.generateFaceModel();
isLivenessCheckPassed.value = true;
isSelfieValid.value = true;
selfieImageFaceId.value = selfieFace.value.faceId;
selfieValidationMessage.value =
'Liveness check passed! Your face is verified.';
// Now that we have a valid selfie with liveness verification, compare with ID card
await compareWithIDCardPhoto();
} catch (e) {
_handleError('Failed to process liveness image', e);
} finally {
isPerformingLivenessCheck.value = false;
}
}
// MARK: - Public Methods
/// Validate the selfie data for form submission
bool validate() {
clearErrors();
if (selfieImage.value == null) {
selfieError.value = 'Please take a selfie for verification';
isFormValid.value = false;
} else if (!isSelfieValid.value) {
selfieError.value = 'Your selfie image is not valid';
isFormValid.value = false;
} else if (!hasConfirmedSelfie.value) {
selfieError.value = 'Please confirm your selfie image';
isFormValid.value = false;
}
return isFormValid.value;
}
/// Clear all error messages
void clearErrors() {
selfieError.value = '';
selfieValidationMessage.value = '';
}
/// Perform liveness detection
Future<void> performLivenessDetection() async {
try {
_setLoading(isPerformingLivenessCheck: true);
// Reset any existing selfie data
_resetVerificationData();
// Navigate to liveness detection page
Get.toNamed('/liveness-detection');
// Processing will continue when liveness detection is complete, handled by _processCapturedLivenessImage()
} catch (e) {
_handleError('Failed to start liveness detection', e);
_setLoading(isPerformingLivenessCheck: false);
}
}
/// Take or pick selfie image manually (fallback)
Future<void> pickSelfieImage(ImageSource source) async {
try {
_setLoading(isUploadingSelfie: true);
_resetVerificationData();
final XFile? image = await _pickImage(source);
if (image == null) return;
if (!await _isFileSizeValid(image)) {
selfieError.value =
'Image size exceeds 4MB limit. Please take a lower resolution photo.';
return;
}
selfieImage.value = image;
await validateSelfieImage();
} catch (e) {
_handleError('Failed to capture selfie', e);
} finally {
_setLoading(isUploadingSelfie: false);
}
}
/// Manual validation (for images taken without liveness check)
Future<void> validateSelfieImage() async {
clearErrors();
if (selfieImage.value == null) {
selfieError.value = 'Please take a selfie first';
isSelfieValid.value = false;
return;
}
if (_facialVerificationService.skipFaceVerification) {
await _handleDevelopmentModeValidation();
return;
}
try {
_setLoading(isVerifyingFace: true);
// Detect faces using EdgeFunction via FacialVerificationService
final bool faceDetected = await _facialVerificationService
.detectFaceInImage(selfieImage.value!);
if (faceDetected) {
// Create a face model - but mark as not live verified since it was taken manually
final faces = await _facialVerificationService.detectFaces(
selfieImage.value!,
);
if (faces.isNotEmpty) {
selfieFace.value = faces.first.withLiveness(
isLive: false,
confidence: 0.0,
message: 'Face detected, but liveness not verified',
);
} else {
selfieFace.value = FaceModel(
imagePath: selfieImage.value!.path,
faceId: 'manual-face-${DateTime.now().millisecondsSinceEpoch}',
confidence: 0.7,
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
).withLiveness(
isLive: false,
confidence: 0.0,
message: 'Basic face detection passed, but liveness not verified',
);
}
selfieImageFaceId.value = selfieFace.value.faceId;
isSelfieValid.value = true;
selfieValidationMessage.value =
'Face detected, but liveness not verified. For better security, use liveness detection.';
// Compare with ID card even though no liveness check
await compareWithIDCardPhoto();
} else {
isSelfieValid.value = false;
selfieValidationMessage.value =
'No face detected in the image. Please try again with a clearer photo.';
}
} catch (e) {
_handleError('Validation failed', e);
} finally {
_setLoading(isVerifyingFace: false);
}
}
/// Compare selfie with ID card photo
Future<void> compareWithIDCardPhoto() async {
final idCardController = Get.find<IdCardVerificationController>();
if (selfieImage.value == null ||
idCardController.idCardImage.value == null) {
print('Cannot compare faces: Missing images');
return;
}
try {
_setLoading(isComparingWithIDCard: true);
if (_facialVerificationService.skipFaceVerification) {
await _handleDevelopmentModeComparison(idCardController);
return;
}
// Compare faces using EdgeFunction via FacialVerificationService
final comparisonResult = await _facialVerificationService.compareFaces(
idCardController.idCardImage.value!,
selfieImage.value!,
);
_updateComparisonResult(comparisonResult);
} catch (e) {
print('Face comparison error: $e');
selfieValidationMessage.value = 'Face comparison error: $e';
} finally {
_setLoading(isComparingWithIDCard: false);
}
}
/// Clear Selfie Image and reset all verification data
void clearSelfieImage() {
selfieImage.value = null;
_resetVerificationData();
}
/// Confirm the selfie image after validation
void confirmSelfieImage() {
if (isSelfieValid.value) {
hasConfirmedSelfie.value = true;
}
}
/// Manually trigger face match verification with ID card
Future<void> verifyFaceMatchWithIDCard() async {
if (selfieImage.value == null) {
selfieError.value = 'Please take a selfie first';
return;
}
try {
// Get the ID card controller
final idCardController = Get.find<IdCardVerificationController>();
if (idCardController.idCardImage.value == null) {
selfieValidationMessage.value =
'ID card image is required for comparison';
return;
}
// Compare with ID card photo
await compareWithIDCardPhoto();
} catch (e) {
selfieValidationMessage.value = 'Face verification failed: $e';
}
}
// MARK: - Private Helper Methods
/// Pick an image from the specified source
Future<XFile?> _pickImage(ImageSource source) async {
final ImagePicker picker = ImagePicker();
return picker.pickImage(
source: source,
preferredCameraDevice: CameraDevice.front,
imageQuality: 80,
);
}
/// Check if a file size is within the allowed limit
Future<bool> _isFileSizeValid(XFile file) async {
final fileSize = await File(file.path).length();
return fileSize <= maxFileSizeBytes;
}
/// Update face data with new liveness check results
void _updateFaceData(FaceModel face) {
selfieFace.value = face;
isLivenessCheckPassed.value = face.isLive;
selfieImageFaceId.value = face.faceId;
isSelfieValid.value = face.isLive;
selfieValidationMessage.value = face.message;
}
/// Update comparison results
void _updateComparisonResult(FaceComparisonResult result) {
faceComparisonResult.value = result;
isMatchWithIDCard.value = result.isMatch;
matchConfidence.value = result.confidence;
selfieValidationMessage.value = result.message;
}
/// Reset all verification-related data
void _resetVerificationData() {
// Clear validation state
selfieError.value = '';
selfieValidationMessage.value = '';
isSelfieValid.value = false;
isLivenessCheckPassed.value = false;
hasConfirmedSelfie.value = false;
// Clear face data
selfieFace.value = FaceModel.empty();
// Clear comparison data
faceComparisonResult.value = null;
isMatchWithIDCard.value = false;
matchConfidence.value = 0.0;
selfieImageFaceId.value = '';
}
/// Handle errors in a consistent way
void _handleError(String baseMessage, dynamic error) {
print('$baseMessage: $error');
selfieError.value = '$baseMessage: $error';
isSelfieValid.value = false;
}
/// Set loading states in a consistent way
void _setLoading({
bool? isVerifyingFace,
bool? isUploadingSelfie,
bool? isPerformingLivenessCheck,
bool? isComparingWithIDCard,
}) {
if (isVerifyingFace != null) this.isVerifyingFace.value = isVerifyingFace;
if (isUploadingSelfie != null)
this.isUploadingSelfie.value = isUploadingSelfie;
if (isPerformingLivenessCheck != null)
this.isPerformingLivenessCheck.value = isPerformingLivenessCheck;
if (isComparingWithIDCard != null)
this.isComparingWithIDCard.value = isComparingWithIDCard;
}
/// Handle development mode dummy validation
Future<void> _handleDevelopmentModeValidation() async {
isSelfieValid.value = true;
isLivenessCheckPassed.value = true;
selfieImageFaceId.value =
'dummy-face-id-${DateTime.now().millisecondsSinceEpoch}';
selfieValidationMessage.value =
'Selfie validation successful (development mode)';
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)',
);
await compareWithIDCardPhoto();
}
/// Handle development mode comparison dummy data
Future<void> _handleDevelopmentModeComparison(
IdCardVerificationController idCardController,
) async {
final 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},
);
final comparisonResult = FaceComparisonResult(
sourceFace: sourceFace,
targetFace: selfieFace.value,
isMatch: true,
confidence: 0.91,
message: 'Face matching successful (development mode)',
);
_updateComparisonResult(comparisonResult);
}
}

View File

@ -1,165 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart';
import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/helpers/network_manager.dart';
import 'package:sigap/src/utils/popups/loaders.dart';
class SignInController extends GetxController {
// Singleton instance
static SignInController get instance => Get.find();
final rememberMe = false.obs;
final isPasswordVisible = false.obs;
final localStorage = GetStorage();
final email = TextEditingController();
final password = TextEditingController();
final emailError = ''.obs;
final passwordError = ''.obs;
final isLoading = false.obs;
// GlobalKey<FormState> formKey = GlobalKey<FormState>();
@override
void onInit() {
// Check if remember me is checked
if (localStorage.read('REMEMBER_ME_CHECK') == true) {
email.text = localStorage.read('REMEMBER_ME_EMAIL');
password.text = localStorage.read('REMEMBER_ME_PASSWORD');
}
super.onInit();
}
// Toggle password visibility
void togglePasswordVisibility() {
isPasswordVisible.value = !isPasswordVisible.value;
}
// Sign in method
Future<void> credentialsSignIn(GlobalKey<FormState> formKey) async {
try {
// Start loading
// TFullScreenLoader.openLoadingDialog(
// 'Logging you in....',
// TImages.amongUsLoading,
// );
isLoading.value = true;
// Check connection
final isConected = await NetworkManager.instance.isConnected();
if (!isConected) {
// TFullScreenLoader.stopLoading();
isLoading.value = false;
return;
}
// Form validation
if (!formKey.currentState!.validate()) {
// TFullScreenLoader.stopLoading();
emailError.value = '';
passwordError.value = '';
isLoading.value = false;
return;
}
// Store data if remember me is checked
if (rememberMe.value) {
localStorage.write('REMEMBER_ME_EMAIL', email.text);
localStorage.write('REMEMBER_ME_PASSWORD', password.text);
localStorage.write('REMEMBER_ME_CHECK', rememberMe.value);
}
// Login with credentials
await AuthenticationRepository.instance.signInWithEmailPassword(
email: email.text.trim(),
password: password.text.trim(),
);
// Stop loading
// TFullScreenLoader.stopLoading();
isLoading.value = false;
// Refresh user data
// final updatedUser = await UserRepository.instance.getUserDetailById();
// UserController.instance.user.value = updatedUser;
// Redirect to home screen
AuthenticationRepository.instance.screenRedirect();
} catch (e) {
// Remove loading
// TFullScreenLoader.stopLoading();
isLoading.value = false;
TLoaders.errorSnackBar(title: 'Oh snap', message: e.toString());
}
}
// -- Google Sign In Authentication
Future<void> googleSignIn(GlobalKey<FormState> formKey) async {
try {
// Start loading
// TFullScreenLoader.openLoadingDialog(
// 'Logging you in....',
// TImages.amongUsLoading,
// );
isLoading.value = true;
// Check connection
final isConected = await NetworkManager.instance.isConnected();
if (!isConected) {
// TFullScreenLoader.stopLoading();
isLoading.value = false;
return;
}
// Form validation
if (!formKey.currentState!.validate()) {
// TFullScreenLoader.stopLoading();
emailError.value = '';
passwordError.value = '';
isLoading.value = false;
return;
}
// Login with Google and save user data in Firebase Authtentication
await AuthenticationRepository.instance.signInWithGoogle();
// final role = localStorage.read('TEMP_ROLE');
// Logger().w(['User Data: $user']);
// Stop loading
// TFullScreenLoader.stopLoading();
isLoading.value = false;
// Redirect to home screen
AuthenticationRepository.instance.screenRedirect();
} catch (e) {
// Remove loading
// TFullScreenLoader.stopLoading();
isLoading.value = false;
TLoaders.errorSnackBar(title: 'Oh snap', message: e.toString());
}
}
// Navigate to sign up screen
void goToSignUp() {
Get.toNamed(AppRoutes.signupWithRole);
}
// Navigate to forgot password screen
void goToForgotPassword() {
Get.toNamed(AppRoutes.forgotPassword);
}
}

View File

@ -1,186 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:logger/logger.dart';
import 'package:sigap/src/features/personalization/data/models/models/user_metadata_model.dart';
import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart';
import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/helpers/network_manager.dart';
import 'package:sigap/src/utils/popups/loaders.dart';
import 'package:sigap/src/utils/validators/validation.dart';
class SignUpController extends GetxController {
static SignUpController get instance => Get.find();
// Variable
final storage = GetStorage();
final formKey = GlobalKey<FormState>();
// Privacy policy
final privacyPolicy = false.obs;
// Controllers for form fields
final emailController = TextEditingController();
final passwordController = TextEditingController();
final confirmPasswordController = TextEditingController();
// Observable error messages
final emailError = ''.obs;
final passwordError = ''.obs;
final confirmPasswordError = ''.obs;
// Observable states
final isPasswordVisible = false.obs;
final isConfirmPasswordVisible = false.obs;
final isLoading = false.obs;
final userMetadata = Rx<UserMetadataModel?>(null);
final selectedRole = Rx<dynamic>(null);
@override
void onInit() {
super.onInit();
// Get arguments from StepForm
final arguments = Get.arguments;
if (arguments != null) {
if (arguments['userMetadata'] != null) {
userMetadata.value = arguments['userMetadata'] as UserMetadataModel;
}
if (arguments['role'] != null) {
selectedRole.value = arguments['role'];
}
}
}
// Validators
String? validateEmail(String? value) {
final error = TValidators.validateEmail(value);
emailError.value = error ?? '';
return error;
}
String? validatePassword(String? value) {
final error = TValidators.validatePassword(value);
passwordError.value = error ?? '';
return error;
}
String? validateConfirmPassword(String? value) {
final error = TValidators.validateConfirmPassword(
passwordController.text,
value,
);
confirmPasswordError.value = error ?? '';
return error;
}
// Toggle password visibility
void togglePasswordVisibility() {
isPasswordVisible.value = !isPasswordVisible.value;
}
// Toggle confirm password visibility
void toggleConfirmPasswordVisibility() {
isConfirmPasswordVisible.value = !isConfirmPasswordVisible.value;
}
// Sign Up Function
void signUp() async {
Logger().i('SignUp process started');
try {
// Form validation
if (!formKey.currentState!.validate()) {
Logger().w('Form validation failed');
return;
}
// Check privacy policy acceptance
if (!privacyPolicy.value) {
Logger().w('Privacy policy not accepted');
TLoaders.warningSnackBar(
title: 'Accept Privacy Policy',
message:
'In order to create account, you must accept the privacy policy & terms of use.',
);
return;
}
// Start loading
isLoading.value = true;
Logger().i('Starting signup process');
// Check connection
Logger().i('Checking network connection');
final isConnected = await NetworkManager.instance.isConnected();
if (!isConnected) {
Logger().w('No internet connection');
isLoading.value = false;
TLoaders.errorSnackBar(
title: 'No Internet Connection',
message: 'Please check your internet connection and try again.',
);
return;
}
// Make sure user metadata is available
if (userMetadata.value == null) {
Logger().w('User metadata is missing');
isLoading.value = false;
TLoaders.errorSnackBar(
title: 'Missing Information',
message: 'Please complete your profile information first.',
);
return;
}
// Add email to user metadata
final updatedMetadata = userMetadata.value!.copyWith(
email: emailController.text.trim(),
);
// Register user with Supabase Auth
Logger().i('Registering user with Supabase Auth');
final authResponse = await AuthenticationRepository.instance
.signUpWithCredential(
emailController.text.trim(),
passwordController.text.trim(),
updatedMetadata.toJson().toString(), // Pass user metadata as a JSON string
);
// Store email for verification or next steps
storage.write('CURRENT_USER_EMAIL', emailController.text.trim());
// Remove loading
Logger().i('Signup process completed');
isLoading.value = false;
// Show success message
Logger().i('Showing success message');
TLoaders.successSnackBar(
title: 'Account Created',
message: 'Please check your email to verify your account!',
);
// Navigate to email verification
Get.offNamed(
AppRoutes.emailVerification,
arguments: {'email': authResponse.user?.email},
);
} catch (e) {
// Handle error
Logger().e('Error occurred: $e');
isLoading.value = false;
// Show error to the user
TLoaders.errorSnackBar(
title: 'Registration Failed',
message: e.toString(),
);
}
}
// Navigate to sign in screen
void goToSignIn() {
Get.offNamed(AppRoutes.signIn);
}
}

View File

@ -1,325 +0,0 @@
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';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart';
class SelfieVerificationController extends GetxController {
// Singleton instance
static SelfieVerificationController get instance => Get.find();
// Services - Use FacialVerificationService instead of direct EdgeFunction
final FacialVerificationService _faceService =
FacialVerificationService.instance;
// Maximum allowed file size in bytes (4MB)
final int maxFileSizeBytes = 4 * 1024 * 1024; // 4MB in bytes
// For this step, we just need to ensure selfie is uploaded and validated
final RxBool isFormValid = RxBool(true);
// Face verification variables
final Rx<XFile?> selfieImage = Rx<XFile?>(null);
final RxString selfieError = RxString('');
final RxBool isVerifyingFace = RxBool(false);
final RxBool isSelfieValid = RxBool(false);
final RxString selfieValidationMessage = RxString('');
final RxBool isLivenessCheckPassed = RxBool(false);
final RxBool isPerformingLivenessCheck = RxBool(false);
// Loading states for image uploading
final RxBool isUploadingSelfie = RxBool(false);
// Confirmation status
final RxBool hasConfirmedSelfie = RxBool(false);
// Face comparison with ID card photo
final RxBool isComparingWithIDCard = RxBool(false);
// Use FaceModel to store selfie face details
final Rx<FaceModel> selfieFace = Rx<FaceModel>(FaceModel.empty());
// Use FaceComparisonResult to store comparison results
final Rx<FaceComparisonResult?> faceComparisonResult =
Rx<FaceComparisonResult?>(null);
// For backward compatibility
final RxBool isMatchWithIDCard = RxBool(false);
final RxDouble matchConfidence = RxDouble(0.0);
final RxString selfieImageFaceId = RxString('');
bool validate() {
clearErrors();
if (selfieImage.value == null) {
selfieError.value = 'Please take a selfie for verification';
isFormValid.value = false;
} else if (!isSelfieValid.value) {
selfieError.value = 'Your selfie image is not valid';
isFormValid.value = false;
} else if (!hasConfirmedSelfie.value) {
selfieError.value = 'Please confirm your selfie image';
isFormValid.value = false;
}
return isFormValid.value;
}
void clearErrors() {
selfieError.value = '';
selfieValidationMessage.value = '';
}
void clearSelfieValidationMessage() {
selfieValidationMessage.value = '';
}
// Take or pick selfie image with file size validation
Future<void> pickSelfieImage(ImageSource source) async {
try {
isUploadingSelfie.value = true;
hasConfirmedSelfie.value = false; // Reset confirmation when image changes
// Reset face data
selfieFace.value = FaceModel.empty();
faceComparisonResult.value = null;
isMatchWithIDCard.value = false;
matchConfidence.value = 0.0;
selfieImageFaceId.value = '';
final ImagePicker picker = ImagePicker();
final XFile? image = await picker.pickImage(
source: source,
preferredCameraDevice: CameraDevice.front,
imageQuality: 80, // Reduce quality to help with file size
);
if (image != null) {
// Check file size
final File file = File(image.path);
final int fileSize = await file.length();
if (fileSize > maxFileSizeBytes) {
selfieError.value =
'Image size exceeds 4MB limit. Please take a lower resolution photo.';
isSelfieValid.value = false;
return;
}
// Add artificial delay to show loading state
await Future.delayed(const Duration(seconds: 1));
selfieImage.value = image;
selfieError.value = '';
// Initial validation of the selfie (face detection)
await validateSelfieImage();
}
} catch (e) {
selfieError.value = 'Failed to capture selfie: $e';
isSelfieValid.value = false;
} finally {
isUploadingSelfie.value = false;
}
}
// Initial validation of selfie image using FacialVerificationService
Future<void> validateSelfieImage() async {
// Clear previous validation messages
clearErrors();
if (selfieImage.value == null) {
selfieError.value = 'Please take a selfie first';
isSelfieValid.value = false;
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 {
isVerifyingFace.value = true;
// Use FacialVerificationService for liveness check
final FaceModel livenessFace = await _faceService.performLivenessCheck(
selfieImage.value!,
);
// Update the face model
selfieFace.value = livenessFace;
// Update liveness status
isLivenessCheckPassed.value = livenessFace.isLive;
// For backward compatibility
selfieImageFaceId.value = livenessFace.faceId;
if (livenessFace.isLive) {
isSelfieValid.value = true;
selfieValidationMessage.value = livenessFace.message;
// Compare with ID card photo if available
await compareWithIDCardPhoto();
} else {
isSelfieValid.value = false;
selfieValidationMessage.value = livenessFace.message;
}
} catch (e) {
isSelfieValid.value = false;
selfieValidationMessage.value = 'Validation failed: ${e.toString()}';
} finally {
isVerifyingFace.value = false;
}
}
// Compare selfie with ID card photo using FacialVerificationService
Future<void> compareWithIDCardPhoto() async {
try {
final idCardController = Get.find<IdCardVerificationController>();
// Check if both images are available
if (selfieImage.value == null ||
idCardController.idCardImage.value == null) {
print('Cannot compare faces: Missing images');
return;
}
isComparingWithIDCard.value = true;
// 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(
idCardController.idCardImage.value!,
selfieImage.value!
);
// Store the comparison result
faceComparisonResult.value = comparisonResult;
// For backward compatibility
isMatchWithIDCard.value = comparisonResult.isMatch;
matchConfidence.value = comparisonResult.confidence;
// Update validation message to include face comparison result
selfieValidationMessage.value = comparisonResult.message;
} catch (e) {
print('Face comparison error: $e');
selfieValidationMessage.value = 'Face comparison error: $e';
} finally {
isComparingWithIDCard.value = false;
}
}
// Manually trigger face comparison with ID card using AWS Rekognition
Future<void> verifyFaceMatchWithIDCard() async {
if (selfieImage.value == null) {
selfieError.value = 'Please take a selfie first';
return;
}
try {
isVerifyingFace.value = true;
// Get the ID card controller
final idCardController = Get.find<IdCardVerificationController>();
if (idCardController.idCardImage.value == null) {
selfieValidationMessage.value =
'ID card image is required for comparison';
return;
}
// Compare faces directly using AWS Rekognition
await compareWithIDCardPhoto();
} catch (e) {
selfieValidationMessage.value = 'Face verification failed: $e';
} finally {
isVerifyingFace.value = false;
}
}
// Clear Selfie Image
void clearSelfieImage() {
selfieImage.value = null;
selfieError.value = '';
isSelfieValid.value = false;
selfieValidationMessage.value = '';
isLivenessCheckPassed.value = false;
hasConfirmedSelfie.value = false;
selfieFace.value = FaceModel.empty();
faceComparisonResult.value = null;
isMatchWithIDCard.value = false;
matchConfidence.value = 0.0;
selfieImageFaceId.value = '';
}
// Confirm Selfie Image
void confirmSelfieImage() {
if (isSelfieValid.value) {
hasConfirmedSelfie.value = true;
}
}
}

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/email_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/basic/email_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/auth_header.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/auth_header.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/otp_input_field.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/otp_input_field.dart';

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/forgot_password_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/basic/forgot_password_controller.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/auth_header.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/auth_header.dart';
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart'; import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';

View File

@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.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/basic/registration_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/id-card-verification/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';

View File

@ -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/registration_form_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/basic/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/identity-verification/identity_verification_controller.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';
@ -23,9 +23,11 @@ class IdentityVerificationStep extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const FormSectionHeader( FormSectionHeader(
title: 'Additional Information', title: 'Additional Information',
subtitle: 'Please provide additional personal details', subtitle: isOfficer
? 'Please provide additional personal details'
: 'Please verify your KTP information below. NIK field cannot be edited.',
), ),
const SizedBox(height: TSizes.spaceBtwItems), const SizedBox(height: TSizes.spaceBtwItems),

View File

@ -0,0 +1,218 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/liveness_detection_controller.dart';
import 'package:sigap/src/utils/constants/colors.dart';
class LivenessDetectionPage extends StatelessWidget {
const LivenessDetectionPage({super.key});
@override
Widget build(BuildContext context) {
final controller = Get.find<FaceLivenessController>();
return Scaffold(
appBar: AppBar(
title: Text('Face Liveness Check'),
leading: IconButton(
icon: Icon(Icons.arrow_back),
onPressed: () {
Get.back();
},
),
),
body: Obx(() {
// Show loading indicator while camera initializes
if (!controller.cameraController!.value.isInitialized) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 20),
Text('Initializing camera...'),
],
),
);
}
// Show captured image when complete
if (controller.isCaptured) {
return _buildCapturedView(controller, context);
}
// Main liveness detection UI
return Column(
children: [
// Instruction banner
Container(
width: double.infinity,
padding: EdgeInsets.all(16),
color: Colors.blue.withOpacity(0.1),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.blue),
SizedBox(width: 12),
Expanded(
child: Text(
controller.getCurrentDirection(),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
),
],
),
),
// Camera preview with face overlay
Expanded(
child: Stack(
alignment: Alignment.center,
children: [
// Camera preview
SizedBox(
width: 300,
height: 300,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: controller.cameraController!.buildPreview(),
),
),
// Oval face guide overlay
Container(
width: 250,
height: 350,
decoration: BoxDecoration(
border: Border.all(color: Colors.white, width: 2),
borderRadius: BorderRadius.circular(150),
),
),
],
),
),
// Completed steps progress
Container(
padding: EdgeInsets.all(16),
color: Colors.grey.shade100,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Verification Progress:',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
SizedBox(height: 8),
// Steps list
...controller.successfulSteps.map(
(step) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
Icon(
Icons.check_circle,
color: Colors.green,
size: 18,
),
SizedBox(width: 8),
Text(step),
],
),
),
),
],
),
),
],
);
}),
);
}
Widget _buildCapturedView(
FaceLivenessController controller,
BuildContext context,
) {
return Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Verification Successful!',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
SizedBox(height: 24),
// Display captured image
if (controller.capturedImage != null)
ClipRRect(
borderRadius: BorderRadius.circular(150),
child: Image.file(
File(controller.capturedImage!.path),
width: 300,
height: 300,
fit: BoxFit.cover,
),
),
SizedBox(height: 24),
// Completed steps list
Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green.withOpacity(0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'All verification steps completed:',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
SizedBox(height: 8),
...controller.successfulSteps.map(
(step) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
Icon(Icons.check_circle, color: Colors.green, size: 18),
SizedBox(width: 8),
Text(step),
],
),
),
),
],
),
),
SizedBox(height: 32),
// Continue button
ElevatedButton(
onPressed: () => Get.back(),
style: ElevatedButton.styleFrom(
backgroundColor: TColors.primary,
minimumSize: Size(double.infinity, 50),
),
child: Text('Continue'),
),
],
),
);
}
}

View File

@ -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/registration_form_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/personal_info_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/viewer-information/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/constants/sizes.dart';

View File

@ -1,13 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/id_card_verification_step.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/identity_verification_step.dart'; import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/id_card_verification_step.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/officer_info_step.dart'; import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/identity_verification_step.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/personal_info_step.dart'; import 'package:sigap/src/features/auth/presentasion/pages/registration-form/officer/officer_info_step.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/selfie_verification_step.dart'; import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/personal_info_step.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/unit_info_step.dart'; import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/selfie_verification_step.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/officer/unit_info_step.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
import 'package:sigap/src/shared/widgets/indicators/step_indicator/step_indicator.dart'; import 'package:sigap/src/shared/widgets/indicators/step_indicator/step_indicator.dart';
import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/colors.dart';
@ -19,6 +20,8 @@ class FormRegistrationScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Ensure all dependencies are registered
final controller = Get.find<FormRegistrationController>(); final controller = Get.find<FormRegistrationController>();
final dark = THelperFunctions.isDarkMode(context); final dark = THelperFunctions.isDarkMode(context);
@ -130,7 +133,6 @@ class FormRegistrationScreen extends StatelessWidget {
child: AuthButton( child: AuthButton(
text: 'Previous', text: 'Previous',
onPressed: controller.previousStep, onPressed: controller.previousStep,
isPrimary: false,
), ),
), ),
) )

View File

@ -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: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/basic/registration_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/facial_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/selfie_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/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';
import 'package:sigap/src/shared/widgets/info/tips_container.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';
@ -55,23 +55,96 @@ class SelfieVerificationStep extends StatelessWidget {
), ),
), ),
// Selfie Upload Widget // Liveness Detection Button
Obx( Padding(
() => ImageUploader( padding: const EdgeInsets.only(bottom: TSizes.spaceBtwItems),
image: controller.selfieImage.value, child: Obx(
title: 'Take a Selfie', () => ElevatedButton.icon(
subtitle: 'Tap to take a selfie (max 4MB)', onPressed:
errorMessage: controller.selfieError.value, controller.isPerformingLivenessCheck.value
isUploading: controller.isUploadingSelfie.value, ? null
isVerifying: controller.isVerifyingFace.value, : controller.performLivenessDetection,
isConfirmed: controller.hasConfirmedSelfie.value, icon:
onTapToSelect: () => _captureSelfie(controller), controller.isPerformingLivenessCheck.value
onClear: controller.clearSelfieImage, ? SizedBox(
onValidate: controller.validateSelfieImage, width: 20,
placeholderIcon: Icons.face, height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Icon(Icons.security),
label: Text(
controller.isPerformingLivenessCheck.value
? 'Processing...'
: 'Perform Liveness Detection',
),
style: ElevatedButton.styleFrom(
backgroundColor: TColors.primary,
foregroundColor: Colors.white,
minimumSize: Size(double.infinity, 45),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(TSizes.buttonRadius),
),
),
),
), ),
), ),
// Selfie Upload Widget (alternative manual method)
Obx(
() =>
controller.selfieImage.value == null
? Container(
margin: const EdgeInsets.only(
bottom: TSizes.spaceBtwItems,
),
padding: const EdgeInsets.all(TSizes.md),
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(
TSizes.borderRadiusMd,
),
border: Border.all(color: Colors.grey.withOpacity(0.3)),
),
child: Column(
children: [
Text(
"Or take a selfie manually",
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: TSizes.sm),
OutlinedButton.icon(
onPressed: () => _captureSelfie(controller),
icon: Icon(Icons.camera_alt),
label: Text('Take Manual Selfie'),
style: OutlinedButton.styleFrom(
minimumSize: Size(double.infinity, 45),
),
),
],
),
)
: ImageUploader(
image: controller.selfieImage.value,
title: 'Selfie Verification',
subtitle:
controller.isLivenessCheckPassed.value
? 'Liveness check passed!'
: 'Your selfie photo',
errorMessage: controller.selfieError.value,
isUploading: controller.isUploadingSelfie.value,
isVerifying: controller.isVerifyingFace.value,
isConfirmed: controller.hasConfirmedSelfie.value,
onTapToSelect: () => _captureSelfie(controller),
onClear: controller.clearSelfieImage,
onValidate: controller.validateSelfieImage,
placeholderIcon: Icons.face,
isSuccess: controller.isLivenessCheckPassed.value,
),
),
// Verification Status for Selfie // Verification Status for Selfie
Obx( Obx(
() => () =>
@ -113,7 +186,7 @@ class SelfieVerificationStep extends StatelessWidget {
: const SizedBox.shrink(), : const SizedBox.shrink(),
), ),
// Face match with ID card indicator - Updated with TipsContainer style // Face match with ID card indicator
Obx(() { Obx(() {
if (controller.selfieImage.value != null && if (controller.selfieImage.value != null &&
controller.isSelfieValid.value) { controller.isSelfieValid.value) {
@ -243,7 +316,7 @@ class SelfieVerificationStep extends StatelessWidget {
), ),
const SizedBox(height: TSizes.xs), const SizedBox(height: TSizes.xs),
Text( Text(
'Make sure your face is well-lit and clearly visible', 'We need to verify that it\'s really you by performing a liveness check',
style: Theme.of( style: Theme.of(
context, context,
).textTheme.bodySmall?.copyWith( ).textTheme.bodySmall?.copyWith(
@ -257,13 +330,14 @@ class SelfieVerificationStep extends StatelessWidget {
Widget _buildSelfieTips() { Widget _buildSelfieTips() {
return TipsContainer( return TipsContainer(
title: 'Tips for a Good Selfie:', title: 'Tips for Liveness Detection:',
tips: [ tips: [
'Find a well-lit area with even lighting', 'Find a well-lit area with even lighting',
'Hold the camera at eye level',
'Look directly at the camera',
'Ensure your entire face is visible',
'Remove glasses and face coverings', 'Remove glasses and face coverings',
'Look directly at the camera',
'Follow the on-screen instructions',
'Rotate your head slowly when prompted',
'Keep your face within the frame'
], ],
backgroundColor: TColors.primary.withOpacity(0.1), backgroundColor: TColors.primary.withOpacity(0.1),
textColor: TColors.primary, textColor: TColors.primary,

View File

@ -1,271 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/image_verification_controller.dart';
import 'package:sigap/src/shared/widgets/form/form_section_header.dart';
import 'package:sigap/src/shared/widgets/form/verification_status.dart';
import 'package:sigap/src/shared/widgets/image_upload/image_source_dialog.dart';
import 'package:sigap/src/shared/widgets/image_upload/image_uploader.dart';
import 'package:sigap/src/shared/widgets/info/tips_container.dart';
import 'package:sigap/src/shared/widgets/verification/validation_message_card.dart';
import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/constants/sizes.dart';
class ImageVerificationStep extends StatelessWidget {
const ImageVerificationStep({super.key});
@override
Widget build(BuildContext context) {
// Initialize the form key
final formKey = GlobalKey<FormState>();
final controller = Get.find<ImageVerificationController>();
final mainController = Get.find<FormRegistrationController>();
mainController.formKey = formKey;
final isOfficer = mainController.selectedRole.value?.isOfficer ?? false;
final String idCardType = isOfficer ? 'KTA' : 'KTP';
return Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const FormSectionHeader(
title: 'Identity Document Verification',
subtitle: 'Please upload your identity documents for verification',
),
// ID Card Upload Section
_buildSectionHeader(
title: '$idCardType Upload',
subtitle: 'Upload a clear image of your $idCardType',
additionalText:
'Make sure all text and your photo are clearly visible',
),
// ID Card Upload Widget
Obx(
() => ImageUploader(
image: controller.idCardImage.value,
title: 'Upload $idCardType Image',
subtitle: 'Tap to select an image',
errorMessage: controller.idCardError.value,
isUploading: controller.isUploadingIdCard.value,
isVerifying: controller.isVerifying.value,
isConfirmed: controller.hasConfirmedIdCard.value,
onTapToSelect:
() => _showImageSourceDialog(controller, true, idCardType),
onClear: controller.clearIdCardImage,
onValidate: controller.validateIdCardImage,
placeholderIcon: Icons.add_a_photo,
),
),
// ID Card Verification Status
Obx(
() => VerificationStatus(
isVerifying:
controller.isVerifying.value &&
!controller.isUploadingIdCard.value,
verifyingMessage: 'Validating your ID card...',
),
),
// ID Card Verification Message
Obx(
() =>
controller.idCardValidationMessage.value.isNotEmpty
? Padding(
padding: const EdgeInsets.symmetric(
vertical: TSizes.spaceBtwItems,
),
child: ValidationMessageCard(
message: controller.idCardValidationMessage.value,
isValid: controller.isIdCardValid.value,
hasConfirmed: controller.hasConfirmedIdCard.value,
onConfirm: controller.confirmIdCardImage,
onTryAnother: controller.clearIdCardImage,
),
)
: const SizedBox.shrink(),
),
// ID Card Tips
const SizedBox(height: TSizes.spaceBtwItems),
_buildIdCardTips(idCardType),
const SizedBox(height: TSizes.spaceBtwSections),
// Selfie Upload Section
_buildSectionHeader(
title: 'Selfie Upload',
subtitle: 'Take a clear selfie for identity verification',
additionalText:
'Make sure your face is well-lit and clearly visible',
),
// Selfie Upload Widget
Obx(
() => ImageUploader(
image: controller.selfieImage.value,
title: 'Take a Selfie',
subtitle: 'Tap to open camera',
errorMessage: controller.selfieError.value,
isUploading: controller.isUploadingSelfie.value,
isVerifying: controller.isVerifyingFace.value,
isConfirmed: controller.hasConfirmedSelfie.value,
onTapToSelect:
() => controller.pickSelfieImage(ImageSource.camera),
onClear: controller.clearSelfieImage,
onValidate: controller.validateSelfieImage,
placeholderIcon: Icons.face,
),
),
// Selfie Verification Status
Obx(
() => VerificationStatus(
isVerifying:
controller.isVerifyingFace.value &&
!controller.isUploadingSelfie.value,
verifyingMessage: 'Validating your selfie...',
),
),
// Selfie Verification Message
Obx(
() =>
controller.selfieValidationMessage.value.isNotEmpty
? Padding(
padding: const EdgeInsets.symmetric(
vertical: TSizes.spaceBtwItems,
),
child: ValidationMessageCard(
message: controller.selfieValidationMessage.value,
isValid: controller.isSelfieValid.value,
hasConfirmed: controller.hasConfirmedSelfie.value,
onConfirm: controller.confirmSelfieImage,
onTryAnother: controller.clearSelfieImage,
),
)
: const SizedBox.shrink(),
),
// Selfie Tips
const SizedBox(height: TSizes.spaceBtwItems),
_buildSelfieTips(),
// Error Messages
Obx(() {
if (controller.idCardError.value.isNotEmpty) {
return Padding(
padding: const EdgeInsets.only(top: TSizes.sm),
child: Text(
controller.idCardError.value,
style: const TextStyle(color: Colors.red),
),
);
}
if (controller.selfieError.value.isNotEmpty) {
return Padding(
padding: const EdgeInsets.only(top: TSizes.sm),
child: Text(
controller.selfieError.value,
style: const TextStyle(color: Colors.red),
),
);
}
return const SizedBox.shrink();
}),
],
),
);
}
Widget _buildSectionHeader({
required String title,
required String subtitle,
required String additionalText,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: TSizes.fontSizeMd,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
),
const SizedBox(height: TSizes.sm),
Text(
subtitle,
style: TextStyle(
fontSize: TSizes.fontSizeSm,
color: TColors.textSecondary,
),
),
const SizedBox(height: TSizes.xs),
Text(
additionalText,
style: TextStyle(
fontSize: TSizes.fontSizeXs,
fontStyle: FontStyle.italic,
color: TColors.textSecondary,
),
),
const SizedBox(height: TSizes.spaceBtwItems),
],
);
}
Widget _buildIdCardTips(String idCardType) {
return TipsContainer(
title: "Tips for a good $idCardType photo:",
tips: [
"Place the card on a dark, non-reflective surface",
"Ensure all four corners are visible",
"Make sure there's good lighting to avoid shadows",
"Your photo and all text should be clearly visible",
"Avoid using flash to prevent glare",
],
backgroundColor: Colors.blue,
textColor: Colors.blue.shade800,
iconColor: Colors.blue,
borderColor: Colors.blue,
);
}
Widget _buildSelfieTips() {
return TipsContainer(
title: "Tips for a good selfie:",
tips: [
"Find a well-lit area with even lighting",
"Hold the camera at eye level",
"Look directly at the camera",
"Ensure your entire face is visible",
"Remove glasses and face coverings",
],
backgroundColor: TColors.primary,
textColor: TColors.primary,
iconColor: TColors.primary,
borderColor: TColors.primary,
);
}
void _showImageSourceDialog(
ImageVerificationController controller,
bool isIdCard,
String idCardType,
) {
ImageSourceDialog.show(
title: 'Select $idCardType Image Source',
message:
'Please ensure your ID card is clear, well-lit, and all text is readable',
onSourceSelected: controller.pickIdCardImage,
galleryOption: isIdCard, // Only allow gallery for ID card
);
}
}

View File

@ -1,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/registration_form_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/officer_info_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/officer-information/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/constants/sizes.dart';

View File

@ -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/registration_form_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/unit_info_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/officer-information/unit_info_controller.dart';
import 'package:sigap/src/shared/widgets/dropdown/custom_dropdown.dart'; import 'package:sigap/src/shared/widgets/dropdown/custom_dropdown.dart';
import 'package:sigap/src/shared/widgets/form/form_section_header.dart'; import 'package:sigap/src/shared/widgets/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';

View File

@ -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/data/models/administrative_division.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/features/auth/presentasion/controllers/basic/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';

View File

@ -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/cores/services/facial_verification_service.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/identity-verification/identity_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/facial_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/info/tips_container.dart';

View File

@ -1,8 +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/identity-verification/identity_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/place_of_birth_field.dart'; import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/place_of_birth_field.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/verification_action_button.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/verification_status_message.dart'; import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/verification_status_message.dart';
import 'package:sigap/src/shared/widgets/form/date_picker_field.dart'; import 'package:sigap/src/shared/widgets/form/date_picker_field.dart';
import 'package:sigap/src/shared/widgets/form/gender_selection.dart'; import 'package:sigap/src/shared/widgets/form/gender_selection.dart';
@ -21,15 +20,6 @@ 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(
@ -39,7 +29,7 @@ class IdInfoForm extends StatelessWidget {
if (!isOfficer) ...[ if (!isOfficer) ...[
// ID Confirmation banner if we have extracted data // ID Confirmation banner if we have extracted data
_buildExtractedDataConfirmation(context), _buildExtractedDataConfirmation(context),
// NIK field for non-officers // NIK field for non-officers
_buildNikField(), _buildNikField(),
@ -92,9 +82,6 @@ class IdInfoForm extends StatelessWidget {
VerificationStatusMessage(controller: controller), VerificationStatusMessage(controller: controller),
const SizedBox(height: TSizes.spaceBtwItems), const SizedBox(height: TSizes.spaceBtwItems),
// Data verification button
VerificationActionButton(controller: controller),
], ],
); );
} }
@ -202,9 +189,14 @@ class IdInfoForm extends StatelessWidget {
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
hintText: 'e.g., 1234567890123456', hintText: 'e.g., 1234567890123456',
onChanged: (value) { onChanged: (value) {
controller.nikController.text = value;
controller.nikError.value = ''; controller.nikError.value = '';
}, },
// Invert the meaning - we want enabled=false when isNikReadOnly=true
enabled: !controller.isNikReadOnly.value,
fillColor:
controller.isNikReadOnly.value
? Colors.grey.shade200
: Colors.transparent,
), ),
); );
} }
@ -220,8 +212,8 @@ class IdInfoForm extends StatelessWidget {
errorText: controller.fullNameError.value, errorText: controller.fullNameError.value,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
hintText: 'Enter your full name as on KTP', hintText: 'Enter your full name as on KTP',
// Remove initialValue to fix the assertion error
onChanged: (value) { onChanged: (value) {
controller.fullNameController.text = value;
controller.fullNameError.value = ''; controller.fullNameError.value = '';
}, },
), ),
@ -241,7 +233,6 @@ class IdInfoForm extends StatelessWidget {
hintText: 'Enter your address as on KTP', hintText: 'Enter your address as on KTP',
maxLines: 3, maxLines: 3,
onChanged: (value) { onChanged: (value) {
controller.addressController.text = value;
controller.addressError.value = ''; controller.addressError.value = '';
}, },
), ),

View File

@ -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/data/models/administrative_division.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/identity-verification/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';
import 'package:sigap/src/utils/constants/sizes.dart'; import 'package:sigap/src/utils/constants/sizes.dart';

View File

@ -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:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/identity-verification/identity_verification_controller.dart';
import 'package:sigap/src/shared/widgets/buttons/custom_elevated_button.dart';
import 'package:sigap/src/utils/constants/sizes.dart'; import 'package:sigap/src/utils/constants/sizes.dart';
class VerificationActionButton extends StatelessWidget { class VerificationActionButton extends StatelessWidget {
@ -10,28 +11,81 @@ class VerificationActionButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx( return Column(
() => ElevatedButton.icon( children: [
onPressed: // Verify data button
controller.isVerifying.value Obx(() {
? null if (controller.isVerifying.value) {
: () => controller.verifyIdCardWithOCR(), return const Center(child: CircularProgressIndicator());
icon: const Icon(Icons.verified_user), }
label: Text(
controller.isVerified.value return CustomElevatedButton(
? 'Re-verify Personal Information' text: 'Verify Information',
: 'Verify Personal Information', onPressed: () => controller.verifyIdCardWithOCR(),
), icon: Icons.check_circle_outline,
style: ElevatedButton.styleFrom( );
backgroundColor: Theme.of(context).primaryColor, }),
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, TSizes.buttonHeight), const SizedBox(height: TSizes.spaceBtwItems),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(TSizes.buttonRadius), // Save data button - only show when verification is complete
), Obx(() {
elevation: 0, if (!controller.isVerified.value) return const SizedBox.shrink();
),
), if (controller.isSavingData.value) {
return Column(
children: [
const Center(child: CircularProgressIndicator()),
const SizedBox(height: TSizes.sm),
Text(
controller.dataSaveMessage.value,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
return Column(
children: [
if (controller.isDataSaved.value)
Container(
margin: const EdgeInsets.only(bottom: TSizes.sm),
padding: const EdgeInsets.all(TSizes.sm),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(TSizes.borderRadiusSm),
),
child: Row(
children: [
Icon(
Icons.check_circle,
color: Colors.green,
size: TSizes.iconSm,
),
const SizedBox(width: TSizes.xs),
Expanded(
child: Text(
controller.dataSaveMessage.value,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.green),
),
),
],
),
),
if (!controller.isDataSaved.value)
CustomElevatedButton(
text: 'Save Registration Data',
onPressed: () => controller.saveRegistrationData(),
icon: Icons.save_outlined,
),
],
);
}),
],
); );
} }
} }

View File

@ -1,6 +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/presentasion/controllers/steps/identity_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/identity-verification/identity_verification_controller.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/sizes.dart'; import 'package:sigap/src/utils/constants/sizes.dart';

View File

@ -2,14 +2,13 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_tabler_icons/flutter_tabler_icons.dart'; import 'package:flutter_tabler_icons/flutter_tabler_icons.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signin_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/basic/signin_controller.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/auth_divider.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/auth_divider.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/auth_header.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/auth_header.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/password_field.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/password_field.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/social_button.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/social_button.dart';
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart'; import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/validators/validation.dart'; import 'package:sigap/src/utils/validators/validation.dart';
class SignInScreen extends StatelessWidget { class SignInScreen extends StatelessWidget {
@ -20,19 +19,24 @@ class SignInScreen extends StatelessWidget {
// Init form key // Init form key
final formKey = GlobalKey<FormState>(); final formKey = GlobalKey<FormState>();
// Get the controller // Get the controller - use Get.put to ensure it's initialized
final controller = Get.find<SignInController>(); final controller = Get.put(SignInController());
// Check if dark mode is enabled
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
// Set system overlay style // Set system overlay style based on theme
SystemChrome.setSystemUIOverlayStyle( SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle( SystemUiOverlayStyle(
statusBarColor: Colors.transparent, statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark, statusBarIconBrightness:
isDarkMode ? Brightness.light : Brightness.dark,
), ),
); );
return Scaffold( return Scaffold(
backgroundColor: TColors.light, // Use dynamic background color from theme
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: SafeArea( body: SafeArea(
child: SingleChildScrollView( child: SingleChildScrollView(
child: Padding( child: Padding(
@ -44,7 +48,7 @@ class SignInScreen extends StatelessWidget {
children: [ children: [
const SizedBox(height: 16), const SizedBox(height: 16),
// Header // Header - pass isDarkMode to AuthHeader if needed
const AuthHeader( const AuthHeader(
title: 'Welcome Back', title: 'Welcome Back',
subtitle: 'Sign in to your account to continue', subtitle: 'Sign in to your account to continue',
@ -82,7 +86,7 @@ class SignInScreen extends StatelessWidget {
child: Text( child: Text(
'Forgot Password?', 'Forgot Password?',
style: TextStyle( style: TextStyle(
color: TColors.primary, color: Theme.of(context).primaryColor,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
@ -95,7 +99,7 @@ class SignInScreen extends StatelessWidget {
Obx( Obx(
() => AuthButton( () => AuthButton(
text: 'Sign In', text: 'Sign In',
onPressed: () => controller.credentialsSignIn(formKey), onPressed: () => controller.signIn(formKey),
isLoading: controller.isLoading.value, isLoading: controller.isLoading.value,
), ),
), ),
@ -112,10 +116,10 @@ class SignInScreen extends StatelessWidget {
text: 'Continue with Google', text: 'Continue with Google',
icon: Icon( icon: Icon(
TablerIcons.brand_google, TablerIcons.brand_google,
color: TColors.light, color: Colors.white,
size: 20, size: 20,
), ),
onPressed: () => controller.googleSignIn(formKey), onPressed: () => controller.googleSignIn(),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -126,14 +130,15 @@ class SignInScreen extends StatelessWidget {
children: [ children: [
Text( Text(
'Don\'t have an account?', 'Don\'t have an account?',
style: TextStyle(color: TColors.textSecondary), // Use theme color for text
style: TextStyle(color: Theme.of(context).hintColor),
), ),
TextButton( TextButton(
onPressed: controller.goToSignUp, onPressed: controller.goToSignUp,
child: Text( child: Text(
'Sign Up', 'Sign Up',
style: TextStyle( style: TextStyle(
color: TColors.primary, color: Theme.of(context).primaryColor,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),

View File

@ -1,263 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup_controller.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/password_field.dart';
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
import 'package:sigap/src/utils/constants/colors.dart';
class SignUpScreen extends StatelessWidget {
const SignUpScreen({super.key});
@override
Widget build(BuildContext context) {
// Get the controller
final controller = Get.find<SignUpController>();
// Set system overlay style
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
),
);
return Scaffold(
backgroundColor: TColors.light,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: Text(
'Create Account',
style: TextStyle(
color: TColors.textPrimary,
fontWeight: FontWeight.bold,
),
),
centerTitle: true,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: TColors.textPrimary),
onPressed: () => Get.back(),
),
),
body: SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Form(
key: controller.formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with profile summary
Obx(() => _buildProfileSummary(controller)),
// Credentials section header
const SizedBox(height: 24),
Text(
'Set Your Login Credentials',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
'Create your login information to secure your account',
style: TextStyle(
fontSize: 14,
color: TColors.textSecondary,
),
),
const SizedBox(height: 16),
// Email field
Obx(
() => CustomTextField(
label: 'Email',
controller: controller.emailController,
validator: controller.validateEmail,
keyboardType: TextInputType.emailAddress,
errorText: controller.emailError.value,
textInputAction: TextInputAction.next,
),
),
// Password field
Obx(
() => PasswordField(
label: 'Password',
controller: controller.passwordController,
validator: controller.validatePassword,
isVisible: controller.isPasswordVisible,
errorText: controller.passwordError.value,
onToggleVisibility: controller.togglePasswordVisibility,
textInputAction: TextInputAction.next,
),
),
// Confirm password field
Obx(
() => PasswordField(
label: 'Confirm Password',
controller: controller.confirmPasswordController,
validator: controller.validateConfirmPassword,
isVisible: controller.isConfirmPasswordVisible,
errorText: controller.confirmPasswordError.value,
onToggleVisibility:
controller.toggleConfirmPasswordVisibility,
),
),
const SizedBox(height: 16),
// Privacy policy checkbox
Row(
children: [
Obx(
() => Checkbox(
value: controller.privacyPolicy.value,
onChanged:
(value) =>
controller.privacyPolicy.value = value!,
),
),
Expanded(
child: Text(
'I agree to the Terms of Service and Privacy Policy',
style: TextStyle(color: TColors.textSecondary),
),
),
],
),
const SizedBox(height: 24),
// Sign up button
Obx(
() => AuthButton(
text: 'Create Account',
onPressed: controller.signUp,
isLoading: controller.isLoading.value,
),
),
const SizedBox(height: 16),
// Already have an account
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Already have an account?',
style: TextStyle(color: TColors.textSecondary),
),
TextButton(
onPressed: controller.goToSignIn,
child: Text(
'Sign In',
style: TextStyle(
color: TColors.primary,
fontWeight: FontWeight.w500,
),
),
),
],
),
],
),
),
),
),
),
);
}
Widget _buildProfileSummary(SignUpController controller) {
if (controller.userMetadata.value == null) {
return Container(); // Return empty container if no metadata
}
final metadata = controller.userMetadata.value!;
final isOfficer = metadata.isOfficer;
final name = metadata.name ?? 'User';
final identifier =
isOfficer
? 'NRP: ${metadata.officerData?.nrp ?? 'N/A'}'
: 'NIK: ${metadata.nik ?? 'N/A'}';
final role = isOfficer ? 'Officer' : 'Viewer';
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: TColors.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Profile Summary',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
),
const SizedBox(height: 8),
Row(
children: [
CircleAvatar(
backgroundColor: TColors.primary,
radius: 24,
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : 'U',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 20,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
identifier,
style: TextStyle(
fontSize: 14,
color: TColors.textSecondary,
),
),
const SizedBox(height: 2),
Text(
'Role: $role',
style: TextStyle(
fontSize: 14,
color: TColors.textSecondary,
),
),
],
),
),
],
),
],
),
);
}
}

View File

@ -3,7 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_svg/svg.dart'; import 'package:flutter_svg/svg.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup_with_role_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/basic/signup_with_role_controller.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/auth_divider.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/auth_divider.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/password_field.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/password_field.dart';

View File

@ -1,13 +1,10 @@
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';
class AuthButton extends StatelessWidget { class AuthButton extends StatelessWidget {
final String text; final String text;
final VoidCallback onPressed; final VoidCallback onPressed;
final bool isLoading; final bool isLoading;
final bool isPrimary;
final bool isDisabled;
final Color? backgroundColor; final Color? backgroundColor;
final Color? textColor; final Color? textColor;
@ -16,47 +13,42 @@ class AuthButton extends StatelessWidget {
required this.text, required this.text,
required this.onPressed, required this.onPressed,
this.isLoading = false, this.isLoading = false,
this.isPrimary = true,
this.isDisabled = false,
this.backgroundColor, this.backgroundColor,
this.textColor, this.textColor,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final effectiveBackgroundColor =
backgroundColor ?? (isPrimary ? TColors.primary : Colors.grey.shade200);
final effectiveTextColor =
textColor ?? (isPrimary ? Colors.white : TColors.textPrimary);
return SizedBox( return SizedBox(
width: double.infinity, width: double.infinity,
height: 50,
child: ElevatedButton( child: ElevatedButton(
onPressed: (isLoading || isDisabled) ? null : onPressed, onPressed: isLoading ? null : onPressed,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: effectiveBackgroundColor, backgroundColor: backgroundColor ?? Theme.of(context).primaryColor,
foregroundColor: effectiveTextColor, foregroundColor: textColor ?? Colors.white,
padding: const EdgeInsets.symmetric(vertical: TSizes.md), elevation: 1,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(TSizes.buttonRadius), borderRadius: BorderRadius.circular(TSizes.buttonRadius),
), ),
elevation: 1,
disabledBackgroundColor: effectiveBackgroundColor.withOpacity(0.5),
), ),
child: child:
isLoading isLoading
? SizedBox( ? SizedBox(
height: 20, width: 24,
width: 20, height: 24,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2.5,
valueColor: AlwaysStoppedAnimation<Color>( color: textColor ?? Colors.white,
effectiveTextColor,
),
), ),
) )
: Text(text, : Text(
style: TextStyle(fontWeight: FontWeight.bold)), text,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
), ),
); );
} }

View File

@ -0,0 +1,145 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class PanicButtonController extends GetxController {
static PanicButtonController get instance => Get.find();
// Observable variables
final RxBool isPanicActive = false.obs;
final RxString locationString = "Indonesia · 125.161.172.145".obs;
final RxInt elapsedSeconds = 0.obs;
Timer? _timer;
@override
void onInit() {
super.onInit();
// Simulate fetching location
_fetchLocation();
}
@override
void onClose() {
_timer?.cancel();
super.onClose();
}
// Toggle panic mode on/off
void togglePanicMode() {
if (isPanicActive.value) {
// If currently active, show confirmation dialog
Get.dialog(
AlertDialog(
title: const Text('Cancel Emergency Alert?'),
content: const Text(
'Are you sure you want to cancel the emergency alert?',
),
actions: [
TextButton(onPressed: () => Get.back(), child: const Text('No')),
TextButton(
onPressed: () {
Get.back();
_deactivatePanicMode();
},
child: const Text('Yes'),
),
],
),
);
} else {
// If not active, show confirmation to activate
Get.dialog(
AlertDialog(
title: const Text('Confirm Emergency Alert'),
content: const Text(
'Are you sure you want to send an emergency alert?',
),
actions: [
TextButton(
onPressed: () => Get.back(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Get.back();
_activatePanicMode();
},
child: const Text(
'Send Alert',
style: TextStyle(color: Colors.red),
),
),
],
),
);
}
}
void _activatePanicMode() {
isPanicActive.value = true;
elapsedSeconds.value = 0;
_startTimer();
// Show toast notification
Get.snackbar(
'Emergency Alert Active',
'Help has been notified and is on the way',
backgroundColor: Colors.red,
colorText: Colors.white,
icon: const Icon(Icons.warning_amber_rounded, color: Colors.white),
snackPosition: SnackPosition.TOP,
duration: const Duration(seconds: 3),
);
// TODO: Implement actual emergency services notification
}
void _deactivatePanicMode() {
isPanicActive.value = false;
_timer?.cancel();
// Show toast notification
Get.snackbar(
'Emergency Alert Cancelled',
'Your emergency alert has been cancelled',
backgroundColor: Colors.green,
colorText: Colors.white,
icon: const Icon(Icons.check_circle, color: Colors.white),
snackPosition: SnackPosition.TOP,
duration: const Duration(seconds: 3),
);
// TODO: Implement actual emergency services cancellation
}
void _startTimer() {
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
elapsedSeconds.value++;
});
}
void _fetchLocation() {
// TODO: Implement actual location fetching
// This is just a placeholder that simulates location fetch
Future.delayed(const Duration(seconds: 2), () {
locationString.value = "Jakarta, Indonesia · 125.161.172.145";
});
}
// Format elapsed time as string
String get elapsedTimeString {
final seconds = elapsedSeconds.value;
if (seconds < 60) {
return '$seconds seconds ago';
}
final minutes = seconds ~/ 60;
if (minutes < 60) {
return '$minutes minutes ago';
}
final hours = minutes ~/ 60;
return '$hours hours ago';
}
}

View File

@ -0,0 +1,268 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/panic/controllers/panic_button_controller.dart';
class PanicButtonPage extends StatefulWidget {
const PanicButtonPage({super.key});
@override
State<PanicButtonPage> createState() => _PanicButtonPageState();
}
class _PanicButtonPageState extends State<PanicButtonPage> {
final controller = Get.put(PanicButtonController());
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF0C1323), Color(0xFF223142)],
),
),
child: SafeArea(
child: Column(
children: [
_buildAppBar(),
const Spacer(),
_buildStatusIndicator(),
const SizedBox(height: 100),
_buildPanicButton(),
const Spacer(),
_buildLocationInfo(),
const SizedBox(height: 20),
],
),
),
),
);
}
Widget _buildAppBar() {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Obx(
() => Text(
controller.isPanicActive.value ? "SOS ACTIVE" : "Emergency Alert",
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(8),
child: const Icon(Icons.more_vert, color: Colors.white),
),
],
),
);
}
Widget _buildStatusIndicator() {
return Obx(() {
if (controller.isPanicActive.value) {
// Active state - show pulsating effect
return Column(
children: [
Container(
width: 180,
height: 180,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.red.withOpacity(0.2),
),
child: Center(
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.red.withOpacity(0.4),
),
child: Center(
child: Container(
width: 100,
height: 100,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.red,
),
child: const Center(
child: Icon(
Icons.warning_amber_rounded,
color: Colors.white,
size: 50,
),
),
),
),
),
),
),
const SizedBox(height: 24),
const Text(
"Help is on the way",
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
"Signal sent ${controller.elapsedTimeString}",
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 16,
),
),
],
);
} else {
// Inactive state - ready to activate
return Column(
children: [
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.teal.shade300, Colors.teal.shade600],
),
),
child: const Center(
child: Icon(
Icons.shield_outlined,
color: Colors.white,
size: 60,
),
),
),
const SizedBox(height: 24),
const Text(
"You are unprotected",
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
"Tap the button below in case of emergency",
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 16,
),
textAlign: TextAlign.center,
),
],
);
}
});
}
Widget _buildPanicButton() {
return Obx(() {
final isPanicActive = controller.isPanicActive.value;
return GestureDetector(
onTap: () => controller.togglePanicMode(),
child: Container(
width: 200,
height: 60,
decoration: BoxDecoration(
color: isPanicActive ? Colors.white : Colors.red,
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(
color: (isPanicActive ? Colors.white : Colors.red).withOpacity(
0.5,
),
blurRadius: 20,
spreadRadius: 5,
),
],
),
child: Center(
child: Text(
isPanicActive ? "CANCEL SOS" : "SOS",
style: TextStyle(
color: isPanicActive ? Colors.red : Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
),
);
});
}
Widget _buildLocationInfo() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 24),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(Icons.location_on, color: Colors.white, size: 24),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"Your current location",
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Obx(
() => Text(
controller.locationString.value,
style: TextStyle(
color: Colors.white.withOpacity(0.7),
fontSize: 14,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
const SizedBox(width: 8),
Icon(Icons.chevron_right, color: Colors.white.withOpacity(0.7)),
],
),
);
}
}

View File

@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:sigap/src/utils/constants/sizes.dart';
class CustomElevatedButton extends StatelessWidget {
final String text;
final VoidCallback onPressed;
final bool isLoading;
final Color? backgroundColor;
final Color? foregroundColor;
final IconData? icon;
final double? width;
final double height;
final EdgeInsetsGeometry padding;
const CustomElevatedButton({
super.key,
required this.text,
required this.onPressed,
this.isLoading = false,
this.backgroundColor,
this.foregroundColor,
this.icon,
this.width,
this.height = 50,
this.padding = const EdgeInsets.symmetric(
vertical: TSizes.buttonHeight / 2.5,
),
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
width: width,
height: height,
child: ElevatedButton(
onPressed: isLoading ? null : onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor ?? theme.primaryColor,
foregroundColor: foregroundColor ?? Colors.white,
padding: padding,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(TSizes.buttonRadius),
),
),
child:
isLoading
? const Center(
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon, size: 18),
const SizedBox(width: TSizes.xs),
],
Text(
text,
style: theme.textTheme.labelLarge?.copyWith(
color: foregroundColor ?? Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
}
}

View File

@ -13,6 +13,7 @@ class ImageUploader extends StatelessWidget {
final bool isUploading; final bool isUploading;
final bool isVerifying; final bool isVerifying;
final bool isConfirmed; final bool isConfirmed;
final bool isSuccess;
final VoidCallback onTapToSelect; final VoidCallback onTapToSelect;
final VoidCallback? onClear; final VoidCallback? onClear;
final VoidCallback? onValidate; final VoidCallback? onValidate;
@ -30,6 +31,7 @@ class ImageUploader extends StatelessWidget {
required this.isUploading, required this.isUploading,
required this.isVerifying, required this.isVerifying,
required this.isConfirmed, required this.isConfirmed,
this.isSuccess = false,
required this.onTapToSelect, required this.onTapToSelect,
this.onClear, this.onClear,
this.onValidate, this.onValidate,
@ -41,18 +43,22 @@ class ImageUploader extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Background color based on error state or confirmation // Background color based on error state, success or confirmation
final backgroundColor = final backgroundColor =
errorMessage != null && errorMessage!.isNotEmpty errorMessage != null && errorMessage!.isNotEmpty
? TColors.error.withOpacity(0.1) ? TColors.error.withOpacity(0.1)
: isSuccess
? Colors.green.withOpacity(0.1)
: isConfirmed : isConfirmed
? Colors.green.withOpacity(0.1) ? Colors.green.withOpacity(0.1)
: Colors.grey.withOpacity(0.1); : Colors.grey.withOpacity(0.1);
// Determine border color based on error state or confirmation // Determine border color based on error state, success or confirmation
final borderColor = final borderColor =
errorMessage != null && errorMessage!.isNotEmpty errorMessage != null && errorMessage!.isNotEmpty
? TColors.error ? TColors.error
: isSuccess
? Colors.green
: isConfirmed : isConfirmed
? Colors.green ? Colors.green
: Colors.grey.withOpacity(0.5); : Colors.grey.withOpacity(0.5);
@ -63,7 +69,7 @@ class ImageUploader extends StatelessWidget {
if (image == null) if (image == null)
_buildEmptyUploader(backgroundColor, borderColor) _buildEmptyUploader(backgroundColor, borderColor)
else else
_buildImagePreview(borderColor), _buildImagePreview(borderColor, context),
// Show file size information if image is uploaded // Show file size information if image is uploaded
if (image != null) if (image != null)
@ -167,7 +173,7 @@ class ImageUploader extends StatelessWidget {
); );
} }
Widget _buildImagePreview(Color borderColor) { Widget _buildImagePreview(Color borderColor, BuildContext context) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -177,22 +183,49 @@ class ImageUploader extends StatelessWidget {
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
border: Border.all(color: borderColor, width: 2), border: Border.all(
color: isSuccess ? Colors.green : borderColor,
width: 2,
),
), ),
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
ClipRRect( // Make the image tappable for preview
borderRadius: BorderRadius.circular( GestureDetector(
TSizes.borderRadiusMd - 2, onTap: () => _showImagePreview(context),
), child: ClipRRect(
child: Image.file( borderRadius: BorderRadius.circular(
File(image!.path), TSizes.borderRadiusMd - 2,
height: 200, ),
width: double.infinity, child: Image.file(
fit: BoxFit.cover, File(image!.path),
height: 200,
width: double.infinity,
fit: BoxFit.cover,
),
), ),
), ),
// Success indicator (checkmark)
if (isSuccess && !isUploading && !isVerifying)
Positioned(
bottom: 8,
right: 8,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
),
child: const Icon(
Icons.check,
color: Colors.white,
size: TSizes.iconSm,
),
),
),
// Error overlay // Error overlay
if (errorMessage != null && if (errorMessage != null &&
errorMessage!.isNotEmpty && errorMessage!.isNotEmpty &&
@ -253,6 +286,86 @@ class ImageUploader extends StatelessWidget {
); );
} }
// Method to show full-screen image preview
void _showImagePreview(BuildContext context) {
if (image == null) return;
showDialog(
context: context,
builder: (context) {
return Dialog(
insetPadding: EdgeInsets.zero,
backgroundColor: Colors.transparent,
child: GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Stack(
alignment: Alignment.center,
children: [
// Image with interactive viewer for zooming
InteractiveViewer(
minScale: 0.5,
maxScale: 3.0,
child: Image.file(
File(image!.path),
fit: BoxFit.contain,
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
),
),
// Close button at the top
Positioned(
top: 40,
right: 20,
child: Container(
decoration: BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: IconButton(
icon: Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
),
),
// Image info at the bottom
Positioned(
bottom: 40,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(30),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isSuccess ? Icons.check_circle : Icons.image,
color: isSuccess ? Colors.green : Colors.white,
size: 20,
),
SizedBox(width: 8),
Text(
title,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
),
),
);
},
);
}
Widget _defaultErrorOverlay() { Widget _defaultErrorOverlay() {
return Container( return Container(
height: 200, height: 200,

View File

@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/navigation_menu.dart';
class CustomBottomNavigationBar extends StatelessWidget {
const CustomBottomNavigationBar({super.key});
@override
Widget build(BuildContext context) {
final controller = NavigationController.instance;
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -5),
),
],
),
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Obx(
() => Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildNavItem(
context,
"Home",
Icons.home,
0,
controller.selectedIndex.value == 0,
),
_buildNavItem(
context,
"Search",
Icons.search,
1,
controller.selectedIndex.value == 1,
),
_buildPanicButton(context),
_buildNavItem(
context,
"History",
Icons.history,
3,
controller.selectedIndex.value == 3,
),
_buildNavItem(
context,
"Profile",
Icons.person_outline,
4,
controller.selectedIndex.value == 4,
),
],
),
),
),
),
);
}
Widget _buildNavItem(
BuildContext context,
String label,
IconData icon,
int index,
bool isSelected,
) {
return GestureDetector(
onTap: () => NavigationController.instance.changeIndex(index),
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
color: isSelected ? const Color(0xFF5E39F1) : Colors.grey,
size: 24,
),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(
color: isSelected ? const Color(0xFF5E39F1) : Colors.grey,
fontSize: 12,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
],
),
);
}
Widget _buildPanicButton(BuildContext context) {
return GestureDetector(
onTap: () => NavigationController.instance.changeIndex(2),
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: const Color(0xFF5E39F1),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: const Color(0xFF5E39F1).withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: const Icon(Icons.qr_code_scanner, color: Colors.white, size: 30),
),
);
}
}

View File

@ -3,7 +3,8 @@ import 'package:sigap/src/utils/constants/sizes.dart';
class CustomTextField extends StatelessWidget { class CustomTextField extends StatelessWidget {
final String label; final String label;
final TextEditingController controller; final TextEditingController? controller;
final String? initialValue;
final String? Function(String?)? validator; final String? Function(String?)? validator;
final TextInputType? keyboardType; final TextInputType? keyboardType;
final TextInputAction? textInputAction; final TextInputAction? textInputAction;
@ -13,14 +14,17 @@ class CustomTextField extends StatelessWidget {
final Widget? prefixIcon; final Widget? prefixIcon;
final Widget? suffixIcon; final Widget? suffixIcon;
final bool? enabled; final bool? enabled;
final bool readOnly;
final bool obscureText; final bool obscureText;
final void Function(String)? onChanged; final void Function(String)? onChanged;
final Color? accentColor; final Color? accentColor;
final Color? fillColor;
const CustomTextField({ const CustomTextField({
super.key, super.key,
required this.label, required this.label,
required this.controller, this.controller,
this.initialValue,
this.validator, this.validator,
this.keyboardType, this.keyboardType,
this.textInputAction, this.textInputAction,
@ -30,10 +34,16 @@ class CustomTextField extends StatelessWidget {
this.prefixIcon, this.prefixIcon,
this.suffixIcon, this.suffixIcon,
this.enabled = true, this.enabled = true,
this.readOnly = false,
this.obscureText = false, this.obscureText = false,
this.onChanged, this.onChanged,
this.accentColor, this.accentColor,
}); this.fillColor,
}) : assert(
// Fix the assertion to avoid duplicate conditions
controller == null || initialValue == null,
'Either provide a controller or an initialValue, not both',
);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -42,6 +52,22 @@ class CustomTextField extends StatelessWidget {
accentColor ?? Theme.of(context).primaryColor; accentColor ?? Theme.of(context).primaryColor;
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
// Determine the effective fill color
final Color effectiveFillColor =
fillColor ??
(isDark
? Theme.of(context).cardColor
: Theme.of(context).inputDecorationTheme.fillColor ??
Colors.grey[100]!);
// Get the common input decoration for both cases
final inputDecoration = _getInputDecoration(
context,
effectiveAccentColor,
isDark,
effectiveFillColor,
);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -55,77 +81,88 @@ class CustomTextField extends StatelessWidget {
), ),
), ),
const SizedBox(height: TSizes.sm), const SizedBox(height: TSizes.sm),
// TextFormField with theme-aware styling
TextFormField(
controller: controller,
validator: validator,
keyboardType: keyboardType,
textInputAction: textInputAction,
maxLines: maxLines,
enabled: enabled,
obscureText: obscureText,
onChanged: onChanged,
// Use the theme's text style
style: Theme.of(context).textTheme.bodyMedium,
decoration: InputDecoration(
hintText: hintText,
hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isDark ? Colors.grey[400] : Colors.grey[600],
),
errorText:
errorText != null && errorText!.isNotEmpty ? errorText : null,
contentPadding: const EdgeInsets.symmetric(
horizontal: TSizes.md,
vertical: TSizes.md,
),
prefixIcon: prefixIcon,
suffixIcon: suffixIcon,
filled: true,
// 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 // Conditional rendering based on whether controller or initialValue is provided
border: OutlineInputBorder( if (controller != null)
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius), TextFormField(
borderSide: BorderSide( controller: controller,
color: Theme.of(context).dividerColor, validator: validator,
width: 1, keyboardType: keyboardType,
), textInputAction: textInputAction,
), maxLines: maxLines,
enabledBorder: OutlineInputBorder( enabled: enabled,
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius), readOnly: readOnly,
borderSide: BorderSide( obscureText: obscureText,
color: Theme.of(context).dividerColor, onChanged: onChanged,
width: 1, style: Theme.of(context).textTheme.bodyMedium,
), decoration: inputDecoration,
), )
focusedBorder: OutlineInputBorder( else
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius), TextFormField(
borderSide: BorderSide(color: effectiveAccentColor, width: 1.5), initialValue: initialValue,
), validator: validator,
errorBorder: OutlineInputBorder( keyboardType: keyboardType,
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius), textInputAction: textInputAction,
borderSide: BorderSide( maxLines: maxLines,
color: Theme.of(context).colorScheme.error, enabled: enabled,
width: 1, readOnly: readOnly,
), obscureText: obscureText,
), onChanged: onChanged,
focusedErrorBorder: OutlineInputBorder( style: Theme.of(context).textTheme.bodyMedium,
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius), decoration: inputDecoration,
borderSide: BorderSide(
color: Theme.of(context).colorScheme.error,
width: 1.5,
),
),
), ),
),
const SizedBox(height: TSizes.spaceBtwInputFields), const SizedBox(height: TSizes.spaceBtwInputFields),
], ],
); );
} }
InputDecoration _getInputDecoration(
BuildContext context,
Color effectiveAccentColor,
bool isDark,
Color effectiveFillColor,
) {
return InputDecoration(
hintText: hintText,
hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isDark ? Colors.grey[400] : Colors.grey[600],
),
errorText: errorText != null && errorText!.isNotEmpty ? errorText : null,
contentPadding: const EdgeInsets.symmetric(
horizontal: TSizes.md,
vertical: TSizes.md,
),
prefixIcon: prefixIcon,
suffixIcon: suffixIcon,
filled: true,
fillColor: effectiveFillColor,
// Use theme-aware border styling
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
borderSide: BorderSide(color: Theme.of(context).dividerColor, width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
borderSide: BorderSide(color: Theme.of(context).dividerColor, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
borderSide: BorderSide(color: effectiveAccentColor, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.error,
width: 1,
),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.error,
width: 1.5,
),
),
);
}
} }

View File

@ -40,10 +40,4 @@ class Endpoints {
static String awsSecretKey = dotenv.env['AWS_SECRET_KEY'] ?? ''; static String awsSecretKey = dotenv.env['AWS_SECRET_KEY'] ?? '';
static String awsRekognitionEndpoint = static String awsRekognitionEndpoint =
'https://rekognition.$awsRegion.amazonaws.com'; 'https://rekognition.$awsRegion.amazonaws.com';
}
// Supabase Edge Functions
static String get detectFace =>
'https://bhfzrlgxqkbkjepvqeva.supabase.co/functions/v1/detect-face';
static String get verifyFace =>
'https://bhfzrlgxqkbkjepvqeva.supabase.co/functions/v1/verify-face';
}

View File

@ -18,4 +18,9 @@ class AppRoutes {
static const String locationWarning = '/location-warning'; static const String locationWarning = '/location-warning';
static const String registrationForm = '/registration-form'; static const String registrationForm = '/registration-form';
static const String signupWithRole = '/signup-with-role'; static const String signupWithRole = '/signup-with-role';
static const String navigationMenu = '/navigation-menu';
static const String idCardVerification = '/id-card-verification';
static const String selfieVerification = '/selfie-verification';
static const String livenessDetection = '/liveness-detection';
} }

View File

@ -105,6 +105,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.9" version: "1.1.9"
camera:
dependency: "direct main"
description:
name: camera
sha256: "413d2b34fe28496c35c69ede5b232fb9dd5ca2c3a4cb606b14efc1c7546cc8cb"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
camera_android_camerax:
dependency: transitive
description:
name: camera_android_camerax
sha256: "9fb44e73e0fea3647a904dc26d38db24055e5b74fc68fd2b6d3abfa1bd20f536"
url: "https://pub.dev"
source: hosted
version: "0.6.17"
camera_avfoundation:
dependency: transitive
description:
name: camera_avfoundation
sha256: ca36181194f429eef3b09de3c96280f2400693f9735025f90d1f4a27465fdd72
url: "https://pub.dev"
source: hosted
version: "0.9.19"
camera_platform_interface:
dependency: transitive
description:
name: camera_platform_interface
sha256: "2f757024a48696ff4814a789b0bd90f5660c0fb25f393ab4564fb483327930e2"
url: "https://pub.dev"
source: hosted
version: "2.10.0"
camera_web:
dependency: transitive
description:
name: camera_web
sha256: "595f28c89d1fb62d77c73c633193755b781c6d2e0ebcd8dc25b763b514e6ba8f"
url: "https://pub.dev"
source: hosted
version: "0.3.5"
carousel_slider: carousel_slider:
dependency: "direct main" dependency: "direct main"
description: description:
@ -653,6 +693,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.3+1" version: "0.3.3+1"
google_mlkit_commons:
dependency: transitive
description:
name: google_mlkit_commons
sha256: "8f40fbac10685cad4715d11e6a0d86837d9ad7168684dfcad29610282a88e67a"
url: "https://pub.dev"
source: hosted
version: "0.11.0"
google_mlkit_face_detection:
dependency: "direct main"
description:
name: google_mlkit_face_detection
sha256: f336737d5b8a86797fd4368f42a5c26aeaa9c6dcc5243f0a16b5f6f663cfb70a
url: "https://pub.dev"
source: hosted
version: "0.13.1"
google_mlkit_face_mesh_detection:
dependency: "direct main"
description:
name: google_mlkit_face_mesh_detection
sha256: "3683daed2463d9631c7f01b31bfc40d22a1fd4c0392d82a24ce275af06bc811f"
url: "https://pub.dev"
source: hosted
version: "0.4.1"
google_sign_in: google_sign_in:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1354,6 +1418,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner: string_scanner:
dependency: transitive dependency: transitive
description: description:

View File

@ -32,6 +32,7 @@ dependencies:
calendar_date_picker2: calendar_date_picker2:
easy_date_timeline: easy_date_timeline:
equatable: ^2.0.7 equatable: ^2.0.7
camera:
# --- Logging & Debugging --- # --- Logging & Debugging ---
logger: logger:
@ -113,6 +114,10 @@ dependencies:
# --- Fonts --- # --- Fonts ---
google_fonts: google_fonts:
# --- Machine Learning ---
google_mlkit_face_detection: ^0.13.1
google_mlkit_face_mesh_detection: ^0.4.1
# --- Localization --- # --- Localization ---
# (add localization dependencies here if needed) # (add localization dependencies here if needed)