diff --git a/sigap-mobile/lib/main.dart b/sigap-mobile/lib/main.dart index 275f57c..0d9c8b0 100644 --- a/sigap-mobile/lib/main.dart +++ b/sigap-mobile/lib/main.dart @@ -1,11 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart'; import 'package:sigap/app.dart'; +import 'package:sigap/src/utils/constants/app_routes.dart'; + import 'package:supabase_flutter/supabase_flutter.dart'; +import 'navigation_menu.dart'; + Future main() async { final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); @@ -43,3 +48,4 @@ Future main() async { runApp(const App()); } + \ No newline at end of file diff --git a/sigap-mobile/lib/navigation_menu.dart b/sigap-mobile/lib/navigation_menu.dart index e69de29..53ed4ba 100644 --- a/sigap-mobile/lib/navigation_menu.dart +++ b/sigap-mobile/lib/navigation_menu.dart @@ -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 selectedIndex = 2.obs; // Start with PanicButtonPage (index 2) + + // Method to change selected index + void changeIndex(int index) { + selectedIndex.value = index; + } +} diff --git a/sigap-mobile/lib/src/cores/bindings/service_bindings.dart b/sigap-mobile/lib/src/cores/bindings/service_bindings.dart index bb1b76f..90d00e8 100644 --- a/sigap-mobile/lib/src/cores/bindings/service_bindings.dart +++ b/sigap-mobile/lib/src/cores/bindings/service_bindings.dart @@ -1,27 +1,23 @@ import 'package:get/get.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/facial_verification_service.dart'; import 'package:sigap/src/cores/services/location_service.dart'; import 'package:sigap/src/cores/services/supabase_service.dart'; + class ServiceBindings extends Bindings { @override Future dependencies() async { - // Initialize background service - + final locationService = await BackgroundService.instance .compute((message) => LocationService(), null); final biometricService = await BackgroundService.instance .compute((message) => BiometricService(), null); - + // Initialize services await Get.putAsync(() => SupabaseService().init(), permanent: true); await Get.putAsync(() => biometricService.init(), permanent: true); await Get.putAsync(() => locationService.init(), permanent: true); - Get.putAsync( - () async => FacialVerificationService.instance, - ); } } diff --git a/sigap-mobile/lib/src/cores/routes/app_pages.dart b/sigap-mobile/lib/src/cores/routes/app_pages.dart index 54f1405..6671fa2 100644 --- a/sigap-mobile/lib/src/cores/routes/app_pages.dart +++ b/sigap-mobile/lib/src/cores/routes/app_pages.dart @@ -1,10 +1,13 @@ 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/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/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/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/onboarding/onboarding_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.signUp, page: () => const SignUpScreen()), - GetPage( name: AppRoutes.signupWithRole, page: () => const SignupWithRoleScreen(), ), - + GetPage( name: AppRoutes.emailVerification, page: () => const EmailVerificationScreen(), @@ -50,11 +51,16 @@ class AppPages { page: () => const ForgotPasswordScreen(), ), - GetPage( name: AppRoutes.locationWarning, page: () => const LocationWarningScreen(), - ) + ), + GetPage(name: AppRoutes.navigationMenu, page: () => const NavigationMenu()), + + GetPage( + name: AppRoutes.livenessDetection, + page: () => const LivenessDetectionPage(), + ), ]; } diff --git a/sigap-mobile/lib/src/cores/services/edge_function_service.dart b/sigap-mobile/lib/src/cores/services/edge_function_service.dart index b610e39..42426ae 100644 --- a/sigap-mobile/lib/src/cores/services/edge_function_service.dart +++ b/sigap-mobile/lib/src/cores/services/edge_function_service.dart @@ -80,90 +80,6 @@ class EdgeFunctionService { throw lastException ?? Exception('Face detection failed'); } - /// Performs liveness detection on a selfie using edge functions with retries - Future performLivenessCheck(XFile selfieImage) async { - int retries = 0; - Exception? lastException; - - while (retries <= _maxRetries) { - try { - // First detect the face - final faces = await detectFaces(selfieImage); - - if (faces.isEmpty) { - return FaceModel.empty().withLiveness( - isLive: false, - confidence: 0.0, - message: 'No face detected in the selfie.', - ); - } - - // Get the primary face - FaceModel face = faces.first; - - // Prepare liveness check payload - final bytes = await File(selfieImage.path).readAsBytes(); - final base64Image = base64Encode(bytes); - - final payload = { - 'image': base64Image, - 'options': {'performLiveness': true}, - }; - - // Call the Supabase Edge Function - final res = await supabase.functions.invoke( - _detectFaceFunction, - body: payload, - ); - - // Process the response - final data = res.data; - - // Extract liveness information - bool isLive = data['isLive'] ?? false; - double confidence = 0.0; - - if (data['livenessScore'] != null) { - // Normalize to 0-1 range - confidence = - (data['livenessScore'] is int || data['livenessScore'] > 1.0) - ? data['livenessScore'] / 100.0 - : data['livenessScore']; - } - - String message = - data['message'] ?? - (isLive - ? 'Liveness check passed.' - : 'Liveness check failed. Please try again.'); - - return face.withLiveness( - isLive: isLive, - confidence: confidence, - message: message, - ); - } catch (e) { - lastException = e is Exception ? e : Exception(e.toString()); - retries++; - - // Wait before retrying - if (retries <= _maxRetries) { - await Future.delayed(Duration(seconds: retries * 2)); - print('Retrying liveness check (attempt $retries)...'); - } - } - } - - // If we get here, all retries failed - print('Liveness check failed after $_maxRetries retries: $lastException'); - return FaceModel.empty().withLiveness( - isLive: false, - confidence: 0.0, - message: - 'Liveness check failed after multiple attempts. Please try again.', - ); - } - /// Compares two face images and returns a comparison result with retries Future compareFaces( XFile sourceImage, diff --git a/sigap-mobile/lib/src/cores/services/facial_verification_service.dart b/sigap-mobile/lib/src/cores/services/facial_verification_service.dart deleted file mode 100644 index 22d9d63..0000000 --- a/sigap-mobile/lib/src/cores/services/facial_verification_service.dart +++ /dev/null @@ -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> 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 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 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); - } -} diff --git a/sigap-mobile/lib/src/cores/services/location_service.dart b/sigap-mobile/lib/src/cores/services/location_service.dart index 0e2c22e..ad74dfa 100644 --- a/sigap-mobile/lib/src/cores/services/location_service.dart +++ b/sigap-mobile/lib/src/cores/services/location_service.dart @@ -43,7 +43,7 @@ class LocationService extends GetxService { //when going to the background foregroundNotificationConfig: const ForegroundNotificationConfig( 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", enableWakeLock: true, ), diff --git a/sigap-mobile/lib/src/features/auth/data/models/face_model.dart b/sigap-mobile/lib/src/features/auth/data/models/face_model.dart index 75a48ad..c78fabc 100644 --- a/sigap-mobile/lib/src/features/auth/data/models/face_model.dart +++ b/sigap-mobile/lib/src/features/auth/data/models/face_model.dart @@ -22,6 +22,33 @@ class FaceModel { final String? gender; 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 final bool isLive; final double livenessConfidence; @@ -41,71 +68,226 @@ class FaceModel { this.maxAge, this.gender, 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.livenessConfidence = 0.0, this.attributes, this.message = '', }); - /// Constructor from edge function response + /// Constructor from edge function response based on Amazon Rekognition format factory FaceModel.fromEdgeFunction( + XFile image, + Map 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, Map faceData, ) { - // Extract faceId if available - final String faceId = faceData['faceId'] ?? faceData['face_id'] ?? ''; - - // Extract confidence - final double confidence = - (faceData['confidence'] ?? faceData['detection_confidence'] ?? 0.0) / - 100.0; - - // Extract bounding box if available + // Extract bounding box Map boundingBox = { 'x': 0.0, 'y': 0.0, 'width': 0.0, 'height': 0.0, }; - if (faceData['boundingBox'] != null || faceData['bounding_box'] != null) { - final box = faceData['boundingBox'] ?? faceData['bounding_box']; + if (faceData['BoundingBox'] != null) { + final box = faceData['BoundingBox']; boundingBox = { - 'x': (box['left'] ?? box['x'] ?? 0.0).toDouble(), - 'y': (box['top'] ?? box['y'] ?? 0.0).toDouble(), - 'width': (box['width'] ?? 0.0).toDouble(), - 'height': (box['height'] ?? 0.0).toDouble(), + 'x': (box['Left'] ?? 0.0).toDouble(), + 'y': (box['Top'] ?? 0.0).toDouble(), + 'width': (box['Width'] ?? 0.0).toDouble(), + 'height': (box['Height'] ?? 0.0).toDouble(), }; } - - // Extract age information if available + + // Extract age range int? minAge; int? maxAge; - if (faceData['age'] != null) { - if (faceData['age'] is Map && faceData['age']['range'] != null) { - minAge = faceData['age']['range']['low']; - maxAge = faceData['age']['range']['high']; - } else if (faceData['age'] is num) { - // Single age value - final age = (faceData['age'] as num).toInt(); - minAge = age - 5; - maxAge = age + 5; - } + if (faceData['AgeRange'] != null) { + minAge = + faceData['AgeRange']['Low'] is num + ? (faceData['AgeRange']['Low'] as num).toInt() + : null; + maxAge = + faceData['AgeRange']['High'] is num + ? (faceData['AgeRange']['High'] as num).toInt() + : null; } - - // Extract gender if available + + // Extract gender String? gender; double? genderConfidence; - if (faceData['gender'] != null) { - if (faceData['gender'] is Map) { - gender = faceData['gender']['value']; - genderConfidence = faceData['gender']['confidence'] / 100.0; - } else if (faceData['gender'] is String) { - gender = faceData['gender']; - genderConfidence = 0.9; // Default confidence - } + if (faceData['Gender'] != null) { + gender = faceData['Gender']['Value']; + genderConfidence = + faceData['Gender']['Confidence'] is num + ? (faceData['Gender']['Confidence'] as num).toDouble() / 100.0 + : null; } - // 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( imagePath: image.path, faceId: faceId, @@ -115,7 +297,25 @@ class FaceModel { maxAge: maxAge, gender: gender, 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, + message: message, ); } @@ -182,6 +382,23 @@ class FaceModel { 'maxAge': maxAge, 'gender': gender, '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, 'livenessConfidence': livenessConfidence, 'message': message, @@ -203,6 +420,15 @@ class FaceComparisonResult { /// Confidence level of the match (0.0-1.0) 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 final String message; @@ -213,6 +439,9 @@ class FaceComparisonResult { required this.targetFace, required this.isMatch, required this.confidence, + this.similarity = 0.0, + this.similarityThreshold = 0.0, + this.confidenceLevel, required this.message, }); @@ -222,25 +451,52 @@ class FaceComparisonResult { FaceModel targetFace, Map response, ) { - bool isMatch = response['isMatch'] ?? false; - double confidence = 0.0; - - if (response['confidence'] != null || response['similarity'] != null) { - confidence = - ((response['confidence'] ?? response['similarity']) ?? 0.0) / 100.0; + // Check if the response is valid + if (!response.containsKey('success') || response['success'] != true) { + return FaceComparisonResult.error( + sourceFace, + targetFace, + '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 = - response['message'] ?? - (isMatch - ? 'Faces match with ${(confidence * 100).toStringAsFixed(1)}% confidence' - : 'Faces do not match'); + // Generate appropriate message + String message; + if (isMatch) { + message = 'Faces match with ${similarity.toStringAsFixed(1)}% similarity'; + 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( sourceFace: sourceFace, targetFace: targetFace, isMatch: isMatch, confidence: confidence, + similarity: similarity, + similarityThreshold: similarityThreshold, + confidenceLevel: confidenceLevel, message: message, ); } diff --git a/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart b/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart index 284cf63..74007dd 100644 --- a/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart +++ b/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart @@ -5,8 +5,8 @@ import 'package:logger/logger.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/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/personalization/data/models/models/user_metadata_model.dart'; import 'package:sigap/src/utils/constants/app_routes.dart'; import 'package:sigap/src/utils/exceptions/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 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 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 // --------------------------------------------------------------------------- diff --git a/sigap-mobile/lib/src/features/auth/presentasion/bindings/auth_bindings.dart b/sigap-mobile/lib/src/features/auth/presentasion/bindings/auth_bindings.dart index b884ea0..ab05c2a 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/bindings/auth_bindings.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/bindings/auth_bindings.dart @@ -1,17 +1,16 @@ 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/forgot_password_controller.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/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/email_verification_controller.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/basic/forgot_password_controller.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/basic/signin_controller.dart'; + +import 'package:sigap/src/features/auth/presentasion/controllers/basic/signup_with_role_controller.dart'; class AuthControllerBindings extends Bindings { @override void dependencies() { // Register all feature auth controllers Get.lazyPut(() => SignInController(), fenix: true); - Get.lazyPut(() => SignUpController(), fenix: true); Get.lazyPut(() => SignupWithRoleController(), fenix: true); Get.lazyPut(() => FormRegistrationController(), fenix: true); Get.lazyPut(() => EmailVerificationController(), fenix: true); diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/email_verification_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/basic/email_verification_controller.dart similarity index 100% rename from sigap-mobile/lib/src/features/auth/presentasion/controllers/email_verification_controller.dart rename to sigap-mobile/lib/src/features/auth/presentasion/controllers/basic/email_verification_controller.dart diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/forgot_password_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/basic/forgot_password_controller.dart similarity index 100% rename from sigap-mobile/lib/src/features/auth/presentasion/controllers/forgot_password_controller.dart rename to sigap-mobile/lib/src/features/auth/presentasion/controllers/basic/forgot_password_controller.dart diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/location_selection_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/basic/location_selection_controller.dart similarity index 100% rename from sigap-mobile/lib/src/features/auth/presentasion/controllers/location_selection_controller.dart rename to sigap-mobile/lib/src/features/auth/presentasion/controllers/basic/location_selection_controller.dart diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/registration_form_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart similarity index 85% rename from sigap-mobile/lib/src/features/auth/presentasion/controllers/registration_form_controller.dart rename to sigap-mobile/lib/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart index fb3768c..1d5cac3 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/registration_form_controller.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart @@ -4,17 +4,16 @@ import 'package:get_storage/get_storage.dart'; import 'package:logger/logger.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/presentasion/controllers/steps/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/steps/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/steps/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/id-card-verification/id_card_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/officer-information/officer_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/selfie-verification/selfie_verification_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/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/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/popups/loaders.dart'; @@ -55,6 +54,11 @@ class FormRegistrationController extends GetxController { // Loading state 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 final Rx idCardData = Rx(null); @@ -460,63 +464,12 @@ class FormRegistrationController extends GetxController { idCardVerificationController.hasConfirmedIdCard.value) { // Get the model from the controller idCardData.value = idCardVerificationController.verifiedIdCardModel; - - } } catch (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(); - // 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(); - // if (!selfieController.validate()) return; - // } else if (currentStep.value == 3) { - // if (selectedRole.value!.isOfficer) { - // // Officer Info Step - // final officerInfoController = Get.find(); - // if (!officerInfoController.validate()) return; - // } else { - // // Identity Verification Step - // final identityVerificationController = - // Get.find(); - // if (!identityVerificationController.validate()) return; - // } - // } else if (currentStep.value == 4 && selectedRole.value!.isOfficer) { - // // Unit Info Step - // final unitInfoController = Get.find(); - // 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 void nextStep() { // Special case for step 1 (ID Card step) @@ -568,6 +521,7 @@ class FormRegistrationController extends GetxController { // Proceed to next step if (currentStep.value < totalSteps - 1) { + // Fixed missing parenthesis currentStep.value++; } else { submitForm(); @@ -625,60 +579,6 @@ class FormRegistrationController extends GetxController { } } - // Submit the complete form - Future 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 void collectAllFormData() { final isOfficerRole = selectedRole.value?.isOfficer ?? false; @@ -771,4 +671,45 @@ class FormRegistrationController extends GetxController { extractedName = idCardController.ktaModel.value?.name ?? ''; } } + + // Submit the entire form + Future 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(); + 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; + } + } } diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/basic/signin_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/basic/signin_controller.dart new file mode 100644 index 0000000..cde7d94 --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/basic/signin_controller.dart @@ -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(); + + // 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 signIn(GlobalKey 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 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; + } + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup_with_role_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/basic/signup_with_role_controller.dart similarity index 100% rename from sigap-mobile/lib/src/features/auth/presentasion/controllers/signup_with_role_controller.dart rename to sigap-mobile/lib/src/features/auth/presentasion/controllers/basic/signup_with_role_controller.dart diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/face_recognition_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/face_recognition_controller.dart deleted file mode 100644 index 65d3e46..0000000 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/face_recognition_controller.dart +++ /dev/null @@ -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 detectedFaces = RxList([]); - final Rx primaryFace = Rx(FaceModel.empty()); - - // Face comparison results - final Rx comparisonResult = Rx( - null, - ); - - /// Validates an image file for processing - Future 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> 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 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 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; - } - } -} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/id-card-verification/id_card_verification_controller.dart similarity index 73% rename from sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart rename to sigap-mobile/lib/src/features/auth/presentasion/controllers/id-card-verification/id_card_verification_controller.dart index 7c76ec6..2e12862 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/id-card-verification/id_card_verification_controller.dart @@ -1,23 +1,21 @@ +import 'dart:convert'; import 'dart:io'; import 'package:get/get.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/facial_verification_service.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/ktp_model.dart'; - + class IdCardVerificationController extends GetxController { // Singleton instance static IdCardVerificationController get instance => Get.find(); // Services final AzureOCRService _ocrService = AzureOCRService(); - // Using FacialVerificationService instead of direct EdgeFunction - final FacialVerificationService _faceService = - FacialVerificationService.instance; - + final bool isOfficer; // Maximum allowed file size in bytes (4MB) @@ -25,6 +23,11 @@ class IdCardVerificationController extends GetxController { 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 final Rx idCardImage = Rx(null); final RxString idCardError = RxString(''); @@ -46,7 +49,7 @@ class IdCardVerificationController extends GetxController { // Add model variables for the extracted data final Rx ktpModel = Rx(null); final Rx ktaModel = Rx(null); - + // Use FaceModel to store face details from ID card final Rx idCardFace = Rx(FaceModel.empty()); @@ -54,6 +57,51 @@ class IdCardVerificationController extends GetxController { final RxString idCardFaceId = RxString(''); final RxBool hasFaceDetected = RxBool(false); + // Save OCR results to local storage + Future _saveOcrResultsToLocalStorage( + Map 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> loadOcrResultsFromLocalStorage() async { + try { + final prefs = await SharedPreferences.getInstance(); + final String? jsonData = prefs.getString(_kOcrResultsKey); + if (jsonData != null) { + final Map 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() { clearErrors(); @@ -131,7 +179,7 @@ class IdCardVerificationController extends GetxController { ktpModel.value = null; ktaModel.value = null; - // Reset face detection data + // Initialize face data with empty model (just to maintain compatibility) idCardFace.value = FaceModel.empty(); idCardFaceId.value = ''; hasFaceDetected.value = false; @@ -160,6 +208,12 @@ class IdCardVerificationController extends GetxController { extractedInfo.assignAll(result); 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 if (isOfficer) { isImageValid = _ocrService.isKtaValid(result); @@ -174,44 +228,8 @@ class IdCardVerificationController extends GetxController { ktpModel.value = _ocrService.createKtpModel(result); } - // Try to detect faces in the ID card image using FacialVerificationService if (isImageValid) { - try { - // 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 - } - + // Instead of detecting faces here, we just save the image reference for later comparison isIdCardValid.value = true; idCardValidationMessage.value = '$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 String? get idCardImagePath => idCardImage.value?.path; - + // Check if the ID card has a detected face bool get hasDetectedFace => idCardFace.value.hasValidFace; // Clear ID Card Image - void clearIdCardImage() { + void clearIdCardImage() async { idCardImage.value = null; idCardError.value = ''; isIdCardValid.value = false; @@ -278,12 +296,34 @@ class IdCardVerificationController extends GetxController { idCardFace.value = FaceModel.empty(); idCardFaceId.value = ''; 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 void confirmIdCardImage() { if (isIdCardValid.value) { 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)}'); + }); } } diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/image_verification_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/id-card-verification/image_verification_controller.dart similarity index 100% rename from sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/image_verification_controller.dart rename to sigap-mobile/lib/src/features/auth/presentasion/controllers/id-card-verification/image_verification_controller.dart diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/identity-verification/identity_verification_controller.dart similarity index 63% rename from sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart rename to sigap-mobile/lib/src/features/auth/presentasion/controllers/identity-verification/identity_verification_controller.dart index 34c1e15..e97b6a5 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/identity-verification/identity_verification_controller.dart @@ -1,14 +1,18 @@ +import 'dart:convert'; + import 'package:flutter/material.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/facial_verification_service.dart'; -// Remove AWS rekognition import completely +import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/facial_verification_controller.dart'; +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/kta_model.dart'; import 'package:sigap/src/features/auth/data/models/ktp_model.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/steps/selfie_verification_controller.dart'; +import 'package:sigap/src/features/auth/data/services/registration_service.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_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 { // Singleton instance @@ -21,6 +25,11 @@ class IdentityVerificationController extends GetxController { final FacialVerificationService _faceService = 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 final TextEditingController nikController = TextEditingController(); final TextEditingController fullNameController = TextEditingController(); @@ -59,11 +68,22 @@ class IdentityVerificationController extends GetxController { // Flag to prevent infinite loop bool _isApplyingData = false; + // NIK field readonly status + final RxBool isNikReadOnly = RxBool(false); + // Properties to store extracted ID card data final String? extractedIdCardNumber; final String? extractedName; final RxBool isPreFilledNik = false.obs; + // Store the loaded OCR data + final RxMap ocrData = RxMap({}); + + // Status of data saving + final RxBool isSavingData = RxBool(false); + final RxBool isDataSaved = RxBool(false); + final RxString dataSaveMessage = RxString(''); + IdentityVerificationController({ this.extractedIdCardNumber = '', this.extractedName = '', @@ -76,14 +96,134 @@ class IdentityVerificationController extends GetxController { // Make sure selectedGender has a default value selectedGender.value = selectedGender.value ?? 'Male'; - // Try to apply ID card data after initialization - Future.microtask(() => _safeApplyIdCardData()); + // Load OCR data from local storage with debug info + print( + 'Initializing IdentityVerificationController and loading OCR data...', + ); + loadOcrDataFromLocalStorage(); } - - // Safely apply ID card data without risking stack overflow + + // Load OCR data from local storage + Future 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 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() { if (_isApplyingData) return; // Guard against recursive calls - + try { _isApplyingData = true; @@ -91,70 +231,21 @@ class IdentityVerificationController extends GetxController { if (!Get.isRegistered()) { return; } - + final formController = Get.find(); if (formController.idCardData.value == null) { return; } - + final idCardData = formController.idCardData.value; if (idCardData != null) { // Fill the form with the extracted data if (!isOfficer && idCardData is KtpModel) { - KtpModel ktpModel = idCardData; - - 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'; + applyKtpDataToForm(idCardData); + isNikReadOnly.value = true; } else if (isOfficer && idCardData is KtaModel) { - KtaModel ktaModel = 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'; + applyKtaDataToForm(idCardData); } } } catch (e) { @@ -195,10 +286,10 @@ class IdentityVerificationController extends GetxController { isFormValid.value = false; } - if (addressController.text.isEmpty) { - addressError.value = 'Address is required'; - isFormValid.value = false; - } + // if (addressController.text.isEmpty) { + // addressError.value = 'Address is required'; + // isFormValid.value = false; + // } return isFormValid.value; } @@ -416,4 +507,38 @@ class IdentityVerificationController extends GetxController { isPreFilledNik.value = true; } + + // Save registration data + Future saveRegistrationData() async { + try { + isSavingData.value = true; + dataSaveMessage.value = 'Saving your registration data...'; + + // Ensure the registration service is available(); + if (!Get.isRegistered()) { + await Get.putAsync(() async => RegistrationService()); + } // Get registration service + + final registrationService = Get.find(); + 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; + } + } } diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/officer_info_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/officer-information/officer_info_controller.dart similarity index 100% rename from sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/officer_info_controller.dart rename to sigap-mobile/lib/src/features/auth/presentasion/controllers/officer-information/officer_info_controller.dart diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/unit_info_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/officer-information/unit_info_controller.dart similarity index 100% rename from sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/unit_info_controller.dart rename to sigap-mobile/lib/src/features/auth/presentasion/controllers/officer-information/unit_info_controller.dart diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection.dart new file mode 100644 index 0000000..ee85535 --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection.dart @@ -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 status = Rx( + 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? _firstPersonEmbedding; + + // Captured Image + final _capturedImage = Rxn(); + XFile? get capturedImage => _capturedImage.value; + + // Successful Steps + final _successfulSteps = [].obs; + List 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 _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 _processCameraImage(CameraImage img) async { + try { + final inputImage = _getInputImageFromCameraImage(img); + if (inputImage == null) return; + + final List 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 _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> _extractFaceEmbeddings(Face face) async { + return [ + face.boundingBox.left, + face.boundingBox.top, + face.boundingBox.right, + face.boundingBox.bottom, + ]; + } + + Future _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 embedding1, + List 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', + ); + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/selfie-verification/facial_verification_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/selfie-verification/facial_verification_controller.dart new file mode 100644 index 0000000..c81b23d --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/selfie-verification/facial_verification_controller.dart @@ -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> 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 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 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 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(); + + // 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)', + ); + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/selfie-verification/liveness_detection_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/selfie-verification/liveness_detection_controller.dart new file mode 100644 index 0000000..5d705f9 --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/selfie-verification/liveness_detection_controller.dart @@ -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? _firstPersonEmbedding; + + // Captured Image + final _capturedImage = Rxn(); + XFile? get capturedImage => _capturedImage.value; + + // Successful Steps + final _successfulSteps = [].obs; + List 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 _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 _processCameraImage(CameraImage img) async { + try { + final inputImage = _getInputImageFromCameraImage(img); + if (inputImage == null) return; + + final List 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 _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> _extractFaceEmbeddings(Face face) async { + return [ + face.boundingBox.left, + face.boundingBox.top, + face.boundingBox.right, + face.boundingBox.bottom, + ]; + } + + Future _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 embedding1, + List 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 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.', + ); + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/selfie-verification/selfie_verification_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/selfie-verification/selfie_verification_controller.dart new file mode 100644 index 0000000..7b86ff6 --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/selfie-verification/selfie_verification_controller.dart @@ -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 selfieImage = Rx(null); + final Rx selfieFace = Rx(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 = + Rx(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 _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 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 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 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 compareWithIDCardPhoto() async { + final idCardController = Get.find(); + + 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 verifyFaceMatchWithIDCard() async { + if (selfieImage.value == null) { + selfieError.value = 'Please take a selfie first'; + return; + } + + try { + // Get the ID card controller + final idCardController = Get.find(); + + 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 _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 _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 _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 _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); + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/signin_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/signin_controller.dart deleted file mode 100644 index b94c6c1..0000000 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/signin_controller.dart +++ /dev/null @@ -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 formKey = GlobalKey(); - - @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 credentialsSignIn(GlobalKey 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 googleSignIn(GlobalKey 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); - } -} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup_controller.dart deleted file mode 100644 index c48d7ab..0000000 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup_controller.dart +++ /dev/null @@ -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(); - - // 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(null); - final selectedRole = Rx(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); - } -} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/selfie_verification_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/selfie_verification_controller.dart deleted file mode 100644 index f951d0d..0000000 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/selfie_verification_controller.dart +++ /dev/null @@ -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 selfieImage = Rx(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 selfieFace = Rx(FaceModel.empty()); - - // Use FaceComparisonResult to store comparison results - final Rx faceComparisonResult = - Rx(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 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 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 compareWithIDCardPhoto() async { - try { - final idCardController = Get.find(); - - // 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 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(); - - 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; - } - } -} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/personal_info_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/viewer-information/personal_info_controller.dart similarity index 100% rename from sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/personal_info_controller.dart rename to sigap-mobile/lib/src/features/auth/presentasion/controllers/viewer-information/personal_info_controller.dart diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/email-verification/email_verification_screen.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/email-verification/email_verification_screen.dart index 7b175f3..d163682 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/email-verification/email_verification_screen.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/email-verification/email_verification_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.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_header.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/otp_input_field.dart'; diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/forgot-password/forgot_password.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/forgot-password/forgot_password.dart index 3596902..ffb8162 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/forgot-password/forgot_password.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/forgot-password/forgot_password.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.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_header.dart'; import 'package:sigap/src/shared/widgets/text/custom_text_field.dart'; diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/id_card_verification_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/id_card_verification_step.dart similarity index 99% rename from sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/id_card_verification_step.dart rename to sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/id_card_verification_step.dart index 377a625..f98b344 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/id_card_verification_step.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/id_card_verification_step.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.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/ktp_model.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_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_uploader.dart'; import 'package:sigap/src/shared/widgets/info/tips_container.dart'; diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/identity_verification_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/identity_verification_step.dart similarity index 81% rename from sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/identity_verification_step.dart rename to sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/identity_verification_step.dart index 7a0552e..1293343 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/identity_verification_step.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/identity_verification_step.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_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/shared/widgets/form/form_section_header.dart'; import 'package:sigap/src/utils/constants/sizes.dart'; @@ -23,9 +23,11 @@ class IdentityVerificationStep extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const FormSectionHeader( + FormSectionHeader( 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), diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/liveness_detection_screen.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/liveness_detection_screen.dart new file mode 100644 index 0000000..49cb44e --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/liveness_detection_screen.dart @@ -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(); + + 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'), + ), + ], + ), + ); + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/personal_info_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/personal_info_step.dart similarity index 97% rename from sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/personal_info_step.dart rename to sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/personal_info_step.dart index 9a79abd..4c14bb7 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/personal_info_step.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/personal_info_step.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/steps/personal_info_controller.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_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/text/custom_text_field.dart'; import 'package:sigap/src/utils/constants/sizes.dart'; diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/registraion_form_screen.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/registraion_form_screen.dart similarity index 94% rename from sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/registraion_form_screen.dart rename to sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/registraion_form_screen.dart index 67b1596..a25ca19 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/registraion_form_screen.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/registraion_form_screen.dart @@ -1,13 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart'; -import 'package:sigap/src/features/auth/presentasion/pages/registration-form/id_card_verification_step.dart'; -import 'package:sigap/src/features/auth/presentasion/pages/registration-form/identity_verification_step.dart'; -import 'package:sigap/src/features/auth/presentasion/pages/registration-form/officer_info_step.dart'; -import 'package:sigap/src/features/auth/presentasion/pages/registration-form/personal_info_step.dart'; -import 'package:sigap/src/features/auth/presentasion/pages/registration-form/selfie_verification_step.dart'; -import 'package:sigap/src/features/auth/presentasion/pages/registration-form/unit_info_step.dart'; + +import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.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/identity_verification_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/basic/personal_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/shared/widgets/indicators/step_indicator/step_indicator.dart'; import 'package:sigap/src/utils/constants/colors.dart'; @@ -19,6 +20,8 @@ class FormRegistrationScreen extends StatelessWidget { @override Widget build(BuildContext context) { + // Ensure all dependencies are registered + final controller = Get.find(); final dark = THelperFunctions.isDarkMode(context); @@ -130,7 +133,6 @@ class FormRegistrationScreen extends StatelessWidget { child: AuthButton( text: 'Previous', onPressed: controller.previousStep, - isPrimary: false, ), ), ) diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/selfie_verification_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/selfie_verification_step.dart similarity index 68% rename from sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/selfie_verification_step.dart rename to sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/selfie_verification_step.dart index ed8fe82..3e0b361 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/selfie_verification_step.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/selfie_verification_step.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; 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/presentasion/controllers/registration_form_controller.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/steps/selfie_verification_controller.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/basic/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/selfie-verification/selfie_verification_controller.dart'; import 'package:sigap/src/shared/widgets/image_upload/image_uploader.dart'; import 'package:sigap/src/shared/widgets/info/tips_container.dart'; import 'package:sigap/src/shared/widgets/verification/validation_message_card.dart'; @@ -55,23 +55,96 @@ class SelfieVerificationStep extends StatelessWidget { ), ), - // Selfie Upload Widget - Obx( - () => ImageUploader( - image: controller.selfieImage.value, - title: 'Take a Selfie', - subtitle: 'Tap to take a selfie (max 4MB)', - errorMessage: controller.selfieError.value, - isUploading: controller.isUploadingSelfie.value, - isVerifying: controller.isVerifyingFace.value, - isConfirmed: controller.hasConfirmedSelfie.value, - onTapToSelect: () => _captureSelfie(controller), - onClear: controller.clearSelfieImage, - onValidate: controller.validateSelfieImage, - placeholderIcon: Icons.face, + // Liveness Detection Button + Padding( + padding: const EdgeInsets.only(bottom: TSizes.spaceBtwItems), + child: Obx( + () => ElevatedButton.icon( + onPressed: + controller.isPerformingLivenessCheck.value + ? null + : controller.performLivenessDetection, + icon: + controller.isPerformingLivenessCheck.value + ? SizedBox( + width: 20, + 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 Obx( () => @@ -113,7 +186,7 @@ class SelfieVerificationStep extends StatelessWidget { : const SizedBox.shrink(), ), - // Face match with ID card indicator - Updated with TipsContainer style + // Face match with ID card indicator Obx(() { if (controller.selfieImage.value != null && controller.isSelfieValid.value) { @@ -243,7 +316,7 @@ class SelfieVerificationStep extends StatelessWidget { ), const SizedBox(height: TSizes.xs), 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( context, ).textTheme.bodySmall?.copyWith( @@ -257,13 +330,14 @@ class SelfieVerificationStep extends StatelessWidget { Widget _buildSelfieTips() { return TipsContainer( - title: 'Tips for a Good Selfie:', + title: 'Tips for Liveness Detection:', 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', + '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), textColor: TColors.primary, diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/image_verification_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/image_verification_step.dart deleted file mode 100644 index 809e415..0000000 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/image_verification_step.dart +++ /dev/null @@ -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(); - final controller = Get.find(); - final mainController = Get.find(); - 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 - ); - } -} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/officer_info_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/officer/officer_info_step.dart similarity index 96% rename from sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/officer_info_step.dart rename to sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/officer/officer_info_step.dart index cefee76..41257e2 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/officer_info_step.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/officer/officer_info_step.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/steps/officer_info_controller.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_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/text/custom_text_field.dart'; import 'package:sigap/src/utils/constants/sizes.dart'; diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/unit_info_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/officer/unit_info_step.dart similarity index 96% rename from sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/unit_info_step.dart rename to sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/officer/unit_info_step.dart index 5f53ce3..bdde40b 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/unit_info_step.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/officer/unit_info_step.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/steps/unit_info_controller.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_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/form/form_section_header.dart'; import 'package:sigap/src/shared/widgets/text/custom_text_field.dart'; diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/city_selection.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/city_selection.dart index aee51d1..1d83434 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/city_selection.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/city_selection.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:sigap/src/features/auth/data/models/administrative_division.dart'; -import 'package:sigap/src/features/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/sizes.dart'; diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/face_verification_section.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/face_verification_section.dart index 7d81815..3e7e59f 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/face_verification_section.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/face_verification_section.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:sigap/src/cores/services/facial_verification_service.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/identity-verification/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/verification_status.dart'; import 'package:sigap/src/shared/widgets/info/tips_container.dart'; diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/id_info_form.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/id_info_form.dart index e89540f..3090c12 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/id_info_form.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/id_info_form.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.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/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/shared/widgets/form/date_picker_field.dart'; import 'package:sigap/src/shared/widgets/form/gender_selection.dart'; @@ -21,15 +20,6 @@ class IdInfoForm extends StatelessWidget { 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 Widget build(BuildContext context) { return Column( @@ -39,7 +29,7 @@ class IdInfoForm extends StatelessWidget { if (!isOfficer) ...[ // ID Confirmation banner if we have extracted data _buildExtractedDataConfirmation(context), - + // NIK field for non-officers _buildNikField(), @@ -92,9 +82,6 @@ class IdInfoForm extends StatelessWidget { VerificationStatusMessage(controller: controller), const SizedBox(height: TSizes.spaceBtwItems), - - // Data verification button - VerificationActionButton(controller: controller), ], ); } @@ -202,9 +189,14 @@ class IdInfoForm extends StatelessWidget { keyboardType: TextInputType.number, hintText: 'e.g., 1234567890123456', onChanged: (value) { - controller.nikController.text = 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, textInputAction: TextInputAction.next, hintText: 'Enter your full name as on KTP', + // Remove initialValue to fix the assertion error onChanged: (value) { - controller.fullNameController.text = value; controller.fullNameError.value = ''; }, ), @@ -241,7 +233,6 @@ class IdInfoForm extends StatelessWidget { hintText: 'Enter your address as on KTP', maxLines: 3, onChanged: (value) { - controller.addressController.text = value; controller.addressError.value = ''; }, ), diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/place_of_birth_field.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/place_of_birth_field.dart index 08a0b49..5adcdef 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/place_of_birth_field.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/place_of_birth_field.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:sigap/src/features/auth/data/models/administrative_division.dart'; -import 'package:sigap/src/features/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/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/sizes.dart'; diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/verification_action_button.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/verification_action_button.dart index 0dfb31c..539c8c8 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/verification_action_button.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/verification_action_button.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.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'; class VerificationActionButton extends StatelessWidget { @@ -10,28 +11,81 @@ class VerificationActionButton extends StatelessWidget { @override Widget build(BuildContext context) { - return Obx( - () => ElevatedButton.icon( - onPressed: - controller.isVerifying.value - ? null - : () => controller.verifyIdCardWithOCR(), - icon: const Icon(Icons.verified_user), - label: Text( - controller.isVerified.value - ? 'Re-verify Personal Information' - : 'Verify Personal Information', - ), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).primaryColor, - foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, TSizes.buttonHeight), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(TSizes.buttonRadius), - ), - elevation: 0, - ), - ), + return Column( + children: [ + // Verify data button + Obx(() { + if (controller.isVerifying.value) { + return const Center(child: CircularProgressIndicator()); + } + + return CustomElevatedButton( + text: 'Verify Information', + onPressed: () => controller.verifyIdCardWithOCR(), + icon: Icons.check_circle_outline, + ); + }), + + const SizedBox(height: TSizes.spaceBtwItems), + + // Save data button - only show when verification is complete + Obx(() { + 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, + ), + ], + ); + }), + ], ); } } diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/verification_status_message.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/verification_status_message.dart index 7e88ffc..862e64c 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/verification_status_message.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/verification_status_message.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.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/utils/constants/sizes.dart'; diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/signin/signin_screen.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/signin/signin_screen.dart index 7428f4a..8a67a18 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/signin/signin_screen.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/signin/signin_screen.dart @@ -2,14 +2,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_tabler_icons/flutter_tabler_icons.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_divider.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/social_button.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'; class SignInScreen extends StatelessWidget { @@ -20,19 +19,24 @@ class SignInScreen extends StatelessWidget { // Init form key final formKey = GlobalKey(); - // Get the controller - final controller = Get.find(); + // Get the controller - use Get.put to ensure it's initialized + 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( - const SystemUiOverlayStyle( + SystemUiOverlayStyle( statusBarColor: Colors.transparent, - statusBarIconBrightness: Brightness.dark, + statusBarIconBrightness: + isDarkMode ? Brightness.light : Brightness.dark, ), ); return Scaffold( - backgroundColor: TColors.light, + // Use dynamic background color from theme + backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: SafeArea( child: SingleChildScrollView( child: Padding( @@ -44,7 +48,7 @@ class SignInScreen extends StatelessWidget { children: [ const SizedBox(height: 16), - // Header + // Header - pass isDarkMode to AuthHeader if needed const AuthHeader( title: 'Welcome Back', subtitle: 'Sign in to your account to continue', @@ -82,7 +86,7 @@ class SignInScreen extends StatelessWidget { child: Text( 'Forgot Password?', style: TextStyle( - color: TColors.primary, + color: Theme.of(context).primaryColor, fontWeight: FontWeight.w500, ), ), @@ -95,7 +99,7 @@ class SignInScreen extends StatelessWidget { Obx( () => AuthButton( text: 'Sign In', - onPressed: () => controller.credentialsSignIn(formKey), + onPressed: () => controller.signIn(formKey), isLoading: controller.isLoading.value, ), ), @@ -112,10 +116,10 @@ class SignInScreen extends StatelessWidget { text: 'Continue with Google', icon: Icon( TablerIcons.brand_google, - color: TColors.light, + color: Colors.white, size: 20, ), - onPressed: () => controller.googleSignIn(formKey), + onPressed: () => controller.googleSignIn(), ), const SizedBox(height: 16), @@ -126,14 +130,15 @@ class SignInScreen extends StatelessWidget { children: [ Text( 'Don\'t have an account?', - style: TextStyle(color: TColors.textSecondary), + // Use theme color for text + style: TextStyle(color: Theme.of(context).hintColor), ), TextButton( onPressed: controller.goToSignUp, child: Text( 'Sign Up', style: TextStyle( - color: TColors.primary, + color: Theme.of(context).primaryColor, fontWeight: FontWeight.w500, ), ), diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/signup_screen.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/signup_screen.dart deleted file mode 100644 index 6a0516d..0000000 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/signup_screen.dart +++ /dev/null @@ -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(); - - // 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, - ), - ), - ], - ), - ), - ], - ), - ], - ), - ); - } -} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/signup_with_role_screen.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/signup_with_role_screen.dart index 29dd991..c50d352 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/signup_with_role_screen.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/signup_with_role_screen.dart @@ -3,7 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_svg/svg.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.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_divider.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/password_field.dart'; diff --git a/sigap-mobile/lib/src/features/auth/presentasion/widgets/auth_button.dart b/sigap-mobile/lib/src/features/auth/presentasion/widgets/auth_button.dart index e3e0dd0..daeee38 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/widgets/auth_button.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/widgets/auth_button.dart @@ -1,13 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/sizes.dart'; class AuthButton extends StatelessWidget { final String text; final VoidCallback onPressed; final bool isLoading; - final bool isPrimary; - final bool isDisabled; final Color? backgroundColor; final Color? textColor; @@ -16,47 +13,42 @@ class AuthButton extends StatelessWidget { required this.text, required this.onPressed, this.isLoading = false, - this.isPrimary = true, - this.isDisabled = false, this.backgroundColor, this.textColor, }); @override Widget build(BuildContext context) { - final effectiveBackgroundColor = - backgroundColor ?? (isPrimary ? TColors.primary : Colors.grey.shade200); - final effectiveTextColor = - textColor ?? (isPrimary ? Colors.white : TColors.textPrimary); - return SizedBox( width: double.infinity, + height: 50, child: ElevatedButton( - onPressed: (isLoading || isDisabled) ? null : onPressed, + onPressed: isLoading ? null : onPressed, style: ElevatedButton.styleFrom( - backgroundColor: effectiveBackgroundColor, - foregroundColor: effectiveTextColor, - padding: const EdgeInsets.symmetric(vertical: TSizes.md), + backgroundColor: backgroundColor ?? Theme.of(context).primaryColor, + foregroundColor: textColor ?? Colors.white, + elevation: 1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(TSizes.buttonRadius), ), - elevation: 1, - disabledBackgroundColor: effectiveBackgroundColor.withOpacity(0.5), ), child: isLoading ? SizedBox( - height: 20, - width: 20, + width: 24, + height: 24, child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - effectiveTextColor, - ), + strokeWidth: 2.5, + color: textColor ?? Colors.white, ), ) - : Text(text, - style: TextStyle(fontWeight: FontWeight.bold)), + : Text( + text, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), ), ); } diff --git a/sigap-mobile/lib/src/features/panic/controllers/panic_button_controller.dart b/sigap-mobile/lib/src/features/panic/controllers/panic_button_controller.dart new file mode 100644 index 0000000..fcadbcb --- /dev/null +++ b/sigap-mobile/lib/src/features/panic/controllers/panic_button_controller.dart @@ -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'; + } +} diff --git a/sigap-mobile/lib/src/features/panic/presentation/pages/panic_button_page.dart b/sigap-mobile/lib/src/features/panic/presentation/pages/panic_button_page.dart new file mode 100644 index 0000000..a2237f4 --- /dev/null +++ b/sigap-mobile/lib/src/features/panic/presentation/pages/panic_button_page.dart @@ -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 createState() => _PanicButtonPageState(); +} + +class _PanicButtonPageState extends State { + 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)), + ], + ), + ); + } +} diff --git a/sigap-mobile/lib/src/shared/widgets/buttons/custom_elevated_button.dart b/sigap-mobile/lib/src/shared/widgets/buttons/custom_elevated_button.dart new file mode 100644 index 0000000..b61fc21 --- /dev/null +++ b/sigap-mobile/lib/src/shared/widgets/buttons/custom_elevated_button.dart @@ -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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/sigap-mobile/lib/src/shared/widgets/image_upload/image_uploader.dart b/sigap-mobile/lib/src/shared/widgets/image_upload/image_uploader.dart index d1a54ea..6a387a0 100644 --- a/sigap-mobile/lib/src/shared/widgets/image_upload/image_uploader.dart +++ b/sigap-mobile/lib/src/shared/widgets/image_upload/image_uploader.dart @@ -13,6 +13,7 @@ class ImageUploader extends StatelessWidget { final bool isUploading; final bool isVerifying; final bool isConfirmed; + final bool isSuccess; final VoidCallback onTapToSelect; final VoidCallback? onClear; final VoidCallback? onValidate; @@ -30,6 +31,7 @@ class ImageUploader extends StatelessWidget { required this.isUploading, required this.isVerifying, required this.isConfirmed, + this.isSuccess = false, required this.onTapToSelect, this.onClear, this.onValidate, @@ -41,18 +43,22 @@ class ImageUploader extends StatelessWidget { @override Widget build(BuildContext context) { - // Background color based on error state or confirmation + // Background color based on error state, success or confirmation final backgroundColor = errorMessage != null && errorMessage!.isNotEmpty ? TColors.error.withOpacity(0.1) + : isSuccess + ? Colors.green.withOpacity(0.1) : isConfirmed ? Colors.green.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 = errorMessage != null && errorMessage!.isNotEmpty ? TColors.error + : isSuccess + ? Colors.green : isConfirmed ? Colors.green : Colors.grey.withOpacity(0.5); @@ -63,7 +69,7 @@ class ImageUploader extends StatelessWidget { if (image == null) _buildEmptyUploader(backgroundColor, borderColor) else - _buildImagePreview(borderColor), + _buildImagePreview(borderColor, context), // Show file size information if image is uploaded if (image != null) @@ -167,7 +173,7 @@ class ImageUploader extends StatelessWidget { ); } - Widget _buildImagePreview(Color borderColor) { + Widget _buildImagePreview(Color borderColor, BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -177,22 +183,49 @@ class ImageUploader extends StatelessWidget { Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), - border: Border.all(color: borderColor, width: 2), + border: Border.all( + color: isSuccess ? Colors.green : borderColor, + width: 2, + ), ), child: Stack( alignment: Alignment.center, children: [ - ClipRRect( - borderRadius: BorderRadius.circular( - TSizes.borderRadiusMd - 2, - ), - child: Image.file( - File(image!.path), - height: 200, - width: double.infinity, - fit: BoxFit.cover, + // Make the image tappable for preview + GestureDetector( + onTap: () => _showImagePreview(context), + child: ClipRRect( + borderRadius: BorderRadius.circular( + TSizes.borderRadiusMd - 2, + ), + child: Image.file( + 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 if (errorMessage != null && 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() { return Container( height: 200, diff --git a/sigap-mobile/lib/src/shared/widgets/navigation/custom_bottom_navigation_bar.dart b/sigap-mobile/lib/src/shared/widgets/navigation/custom_bottom_navigation_bar.dart new file mode 100644 index 0000000..a7faa02 --- /dev/null +++ b/sigap-mobile/lib/src/shared/widgets/navigation/custom_bottom_navigation_bar.dart @@ -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), + ), + ); + } +} diff --git a/sigap-mobile/lib/src/shared/widgets/text/custom_text_field.dart b/sigap-mobile/lib/src/shared/widgets/text/custom_text_field.dart index 8b29b9d..ff2c5a3 100644 --- a/sigap-mobile/lib/src/shared/widgets/text/custom_text_field.dart +++ b/sigap-mobile/lib/src/shared/widgets/text/custom_text_field.dart @@ -3,7 +3,8 @@ import 'package:sigap/src/utils/constants/sizes.dart'; class CustomTextField extends StatelessWidget { final String label; - final TextEditingController controller; + final TextEditingController? controller; + final String? initialValue; final String? Function(String?)? validator; final TextInputType? keyboardType; final TextInputAction? textInputAction; @@ -13,14 +14,17 @@ class CustomTextField extends StatelessWidget { final Widget? prefixIcon; final Widget? suffixIcon; final bool? enabled; + final bool readOnly; final bool obscureText; final void Function(String)? onChanged; final Color? accentColor; + final Color? fillColor; const CustomTextField({ super.key, required this.label, - required this.controller, + this.controller, + this.initialValue, this.validator, this.keyboardType, this.textInputAction, @@ -30,10 +34,16 @@ class CustomTextField extends StatelessWidget { this.prefixIcon, this.suffixIcon, this.enabled = true, + this.readOnly = false, this.obscureText = false, this.onChanged, 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 Widget build(BuildContext context) { @@ -42,6 +52,22 @@ class CustomTextField extends StatelessWidget { accentColor ?? Theme.of(context).primaryColor; 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( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -55,77 +81,88 @@ class CustomTextField extends StatelessWidget { ), ), 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 - 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, - ), - ), + // Conditional rendering based on whether controller or initialValue is provided + if (controller != null) + TextFormField( + controller: controller, + validator: validator, + keyboardType: keyboardType, + textInputAction: textInputAction, + maxLines: maxLines, + enabled: enabled, + readOnly: readOnly, + obscureText: obscureText, + onChanged: onChanged, + style: Theme.of(context).textTheme.bodyMedium, + decoration: inputDecoration, + ) + else + TextFormField( + initialValue: initialValue, + validator: validator, + keyboardType: keyboardType, + textInputAction: textInputAction, + maxLines: maxLines, + enabled: enabled, + readOnly: readOnly, + obscureText: obscureText, + onChanged: onChanged, + style: Theme.of(context).textTheme.bodyMedium, + decoration: inputDecoration, ), - ), 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, + ), + ), + ); + } } diff --git a/sigap-mobile/lib/src/utils/constants/api_urls.dart b/sigap-mobile/lib/src/utils/constants/api_urls.dart index 58e8935..dd7c6cd 100644 --- a/sigap-mobile/lib/src/utils/constants/api_urls.dart +++ b/sigap-mobile/lib/src/utils/constants/api_urls.dart @@ -40,10 +40,4 @@ class Endpoints { static String awsSecretKey = dotenv.env['AWS_SECRET_KEY'] ?? ''; static String awsRekognitionEndpoint = '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'; -} +} diff --git a/sigap-mobile/lib/src/utils/constants/app_routes.dart b/sigap-mobile/lib/src/utils/constants/app_routes.dart index ff36b80..d5c497d 100644 --- a/sigap-mobile/lib/src/utils/constants/app_routes.dart +++ b/sigap-mobile/lib/src/utils/constants/app_routes.dart @@ -18,4 +18,9 @@ class AppRoutes { static const String locationWarning = '/location-warning'; static const String registrationForm = '/registration-form'; 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'; + } diff --git a/sigap-mobile/pubspec.lock b/sigap-mobile/pubspec.lock index 4084bc7..e548fc8 100644 --- a/sigap-mobile/pubspec.lock +++ b/sigap-mobile/pubspec.lock @@ -105,6 +105,46 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -653,6 +693,30 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -1354,6 +1418,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/sigap-mobile/pubspec.yaml b/sigap-mobile/pubspec.yaml index 57dec7a..b501ba2 100644 --- a/sigap-mobile/pubspec.yaml +++ b/sigap-mobile/pubspec.yaml @@ -32,6 +32,7 @@ dependencies: calendar_date_picker2: easy_date_timeline: equatable: ^2.0.7 + camera: # --- Logging & Debugging --- logger: @@ -113,6 +114,10 @@ dependencies: # --- Fonts --- google_fonts: + # --- Machine Learning --- + google_mlkit_face_detection: ^0.13.1 + google_mlkit_face_mesh_detection: ^0.4.1 + # --- Localization --- # (add localization dependencies here if needed)