diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup/main/registration_form_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup/main/registration_form_controller.dart index f1a94ea..9cceec8 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup/main/registration_form_controller.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup/main/registration_form_controller.dart @@ -195,13 +195,6 @@ class FormRegistrationController extends GetxController { if (registrationData.value.roleId?.isNotEmpty == true) { await _setRoleFromMetadata(); } - - if (registrationData.value.isOfficer || - (selectedRole.value?.isOfficer == true)) { - await _fetchAvailableUnits(); - } - - Logger().d('Initialization completed successfully'); } catch (e) { Logger().e('Error completing initialization: $e'); } @@ -227,7 +220,10 @@ class FormRegistrationController extends GetxController { } void _initializeControllers() { - final isOfficer = registrationData.value.isOfficer; + final isOfficer = + AuthenticationRepository.instance.authUser?.userMetadata?['is_officer'] + as bool? ?? + false; Logger().d('Initializing controllers with isOfficer: $isOfficer'); @@ -296,8 +292,10 @@ class FormRegistrationController extends GetxController { permanent: false, ); - Get.put(OfficerInfoController(), permanent: false); - Get.put(UnitInfoController(), permanent: false); + if (isOfficer) { + Get.put(OfficerInfoController(), permanent: false); + Get.put(UnitInfoController(), permanent: false); + } } void _assignControllerReferences(bool isOfficer) { @@ -626,24 +624,6 @@ class FormRegistrationController extends GetxController { } } - Future _fetchAvailableUnits() async { - try { - isLoading.value = true; - await Future.delayed(const Duration(seconds: 1)); - - if (unitInfoController != null) { - // unitInfoController!.availableUnits.value = fetchedUnits; - } - } catch (e) { - TLoaders.errorSnackBar( - title: 'Error', - message: 'Failed to fetch available units: ${e.toString()}', - ); - } finally { - isLoading.value = false; - } - } - // Validate current step bool validateCurrentStep() { switch (currentStep.value) { @@ -737,7 +717,7 @@ class FormRegistrationController extends GetxController { break; case 3: if (selectedRole.value?.isOfficer == true) { - officerInfoController!.clearErrors(); + officerInfoController?.clearErrors(); } else { identityController.clearErrors(); } diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup/step/officer-information/officer_info_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup/step/officer-information/officer_info_controller.dart index c38f355..8da5046 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup/step/officer-information/officer_info_controller.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup/step/officer-information/officer_info_controller.dart @@ -1,15 +1,42 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:logger/logger.dart'; +import 'package:sigap/src/features/daily-ops/data/models/models/patrol_units_model.dart'; +import 'package:sigap/src/features/daily-ops/data/models/models/units_model.dart'; +import 'package:sigap/src/features/daily-ops/data/repositories/patrol_units_repository.dart'; +import 'package:sigap/src/features/daily-ops/data/repositories/units_repository.dart'; +import 'package:sigap/src/utils/popups/loaders.dart'; import 'package:sigap/src/utils/validators/validation.dart'; +// Define enums for patrol unit type and selection mode +enum PatrolUnitType { car, motorcycle } + +enum PatrolSelectionMode { individual, group, createNew } + class OfficerInfoController extends GetxController { // Singleton instance static OfficerInfoController get instance => Get.find(); // Static form key // final GlobalKey formKey = TGlobalFormKey.officerInfo(); - -final RxBool isFormValid = RxBool(true); + + final RxBool isFormValid = RxBool(true); + + // Data states + final RxList availableUnits = [].obs; + final RxList availablePatrolUnits = [].obs; + final RxBool isLoadingUnits = false.obs; + final RxBool isLoadingPatrolUnits = false.obs; + final RxString selectedUnitName = ''.obs; + final RxString selectedPatrolUnitName = ''.obs; + + // Additional data states for patrol unit configuration + final Rx selectedPatrolType = PatrolUnitType.car.obs; + final Rx patrolSelectionMode = + PatrolSelectionMode.individual.obs; + final RxBool isCreatingNewPatrolUnit = false.obs; + final RxString newPatrolUnitName = ''.obs; + // Controllers final nrpController = TextEditingController(); final rankController = TextEditingController(); @@ -25,6 +52,13 @@ final RxBool isFormValid = RxBool(true); final bannedReasonController = TextEditingController(); final bannedUntilController = TextEditingController(); + // Controllers for new patrol unit + final patrolNameController = TextEditingController(); + final patrolTypeController = TextEditingController(); + final patrolRadiusController = TextEditingController( + text: '500', + ); // Default radius in meters + // Error states final RxString nrpError = ''.obs; final RxString rankError = ''.obs; @@ -40,6 +74,256 @@ final RxBool isFormValid = RxBool(true); final RxString bannedReasonError = ''.obs; final RxString bannedUntilError = ''.obs; + // Error states for new patrol unit + final RxString patrolNameError = ''.obs; + final RxString patrolTypeError = ''.obs; + final RxString patrolRadiusError = ''.obs; + + // Logger instance + final Logger logger = Logger(); + + // Make sure repositories are properly initialized + late final UnitRepository unitRepository; + late final PatrolUnitRepository patrolUnitRepository; + + // Dropdown open state + RxBool isUnitDropdownOpen = false.obs; + + @override + void onInit() { + super.onInit(); + + initRepositories(); + + // Fetch units after ensuring repositories are set up + getAvailableUnits(); + } + + void initRepositories() { + // Check if repositories are already registered with GetX + unitRepository = Get.find(); + patrolUnitRepository = Get.find(); + + Logger().i('UnitRepository and PatrolUnitRepository initialized'); + } + + // Fetch available units with improved error handling + void getAvailableUnits() async { + logger.i('Starting to fetch available units'); + + try { + isLoadingUnits.value = true; + availableUnits.clear(); + + logger.i('Calling repository.getAllUnits()'); + final units = await unitRepository.getAllUnits(); + + logger.i('Received ${units.length} units from repository'); + availableUnits.value = units; + + if (units.isEmpty) { + logger.w('No units returned from repository'); + } else { + logger.i('First unit: ${units.first.name}'); + } + + isLoadingUnits.value = false; + } catch (error) { + logger.e('Failed to fetch units: $error'); + isLoadingUnits.value = false; + TLoaders.errorSnackBar( + title: 'Error', + message: + 'Something went wrong while fetching units. Please try again later.', + ); + } + } + + // Fetch patrol units by unit ID + void getPatrolUnitsByUnitId(String unitId) async { + try { + isLoadingPatrolUnits.value = true; + availablePatrolUnits.clear(); + + final patrolUnits = await patrolUnitRepository.getPatrolUnitsByUnitId( + unitId, + ); + + availablePatrolUnits.value = patrolUnits; + + isLoadingPatrolUnits.value = false; + } catch (error) { + isLoadingPatrolUnits.value = false; + TLoaders.errorSnackBar( + title: 'Error', + message: + 'Something went wrong while fetching patrol units. Please try again later.', + ); + Logger().e('Failed to fetch patrol units: $error'); + } + } + + // Handle unit selection + void onUnitSelected(UnitModel unit) { + unitIdController.text = unit.codeUnit; + selectedUnitName.value = unit.name; + + // Clear patrol unit selection + patrolUnitIdController.text = ''; + selectedPatrolUnitName.value = ''; + + // Get patrol units for the selected unit + getPatrolUnitsByUnitId(unit.codeUnit); + } + + // Handle patrol unit selection + void onPatrolUnitSelected(PatrolUnitModel patrolUnit) { + patrolUnitIdController.text = patrolUnit.id; + selectedPatrolUnitName.value = patrolUnit.name; + } + + // Set patrol unit type (car or motorcycle) + void setPatrolUnitType(PatrolUnitType type) { + selectedPatrolType.value = type; + patrolTypeController.text = type.name; + } + + // Set patrol selection mode (individual, group, or create new) + void setPatrolSelectionMode(PatrolSelectionMode mode) { + patrolSelectionMode.value = mode; + + // Reset fields when changing modes + if (mode == PatrolSelectionMode.createNew) { + isCreatingNewPatrolUnit.value = true; + patrolUnitIdController.clear(); + selectedPatrolUnitName.value = ''; + } else { + isCreatingNewPatrolUnit.value = false; + } + } + + // Validate new patrol unit data + bool validateNewPatrolUnit() { + bool isValid = true; + + // Clear previous errors + patrolNameError.value = ''; + patrolTypeError.value = ''; + patrolRadiusError.value = ''; + + // Validate patrol unit name + final nameValidation = TValidators.validateUserInput( + 'Patrol Unit Name', + patrolNameController.text, + 50, + ); + if (nameValidation != null) { + patrolNameError.value = nameValidation; + isValid = false; + } + + // Validate patrol unit type + if (patrolTypeController.text.isEmpty) { + patrolTypeError.value = 'Patrol type is required'; + isValid = false; + } + + // Validate patrol radius + if (patrolRadiusController.text.isEmpty) { + patrolRadiusError.value = 'Patrol radius is required'; + isValid = false; + } else { + try { + final radius = double.parse(patrolRadiusController.text); + if (radius <= 0) { + patrolRadiusError.value = 'Radius must be greater than 0'; + isValid = false; + } + } catch (e) { + patrolRadiusError.value = 'Invalid radius value'; + isValid = false; + } + } + + return isValid; + } + + // Create a new patrol unit + Future createNewPatrolUnit() async { + if (!validateNewPatrolUnit()) { + return false; + } + + if (unitIdController.text.isEmpty) { + TLoaders.errorSnackBar( + title: 'Unit Required', + message: 'Please select a unit before creating a patrol unit', + ); + return false; + } + + try { + // This would typically involve an API call to create the patrol unit + // For now, we'll just simulate success + // In a real implementation, you would call a repository method to create the patrol unit + + // Example of what the real implementation might look like: + /* + final newPatrolUnit = await patrolUnitRepository.createPatrolUnit( + PatrolUnitModel( + id: '', // Will be generated by the backend + unitId: unitIdController.text, + locationId: '', // This might need to be set elsewhere + name: patrolNameController.text, + type: selectedPatrolType.value.name, + status: 'active', // Default status + radius: double.parse(patrolRadiusController.text), + createdAt: DateTime.now(), + ), + ); + + // If successful, set the patrol unit ID and name + patrolUnitIdController.text = newPatrolUnit.id; + selectedPatrolUnitName.value = newPatrolUnit.name; + */ + + // Simulate success + TLoaders.successSnackBar( + title: 'Success', + message: 'Patrol unit created successfully', + ); + return true; + } catch (error) { + TLoaders.errorSnackBar( + title: 'Error', + message: 'Failed to create patrol unit: $error', + ); + return false; + } + } + + // Join an existing patrol unit + void joinPatrolUnit(PatrolUnitModel patrolUnit) { + patrolUnitIdController.text = patrolUnit.id; + selectedPatrolUnitName.value = patrolUnit.name; + + // In a real app, you might want to update the user's patrol unit membership + // This could involve an API call to update the user's patrol unit ID + } + + // Get available patrol units filtered by type + List getFilteredPatrolUnits() { + if (selectedPatrolType.value == PatrolUnitType.car) { + return availablePatrolUnits + .where((unit) => unit.type.toLowerCase() == 'car') + .toList(); + } else { + return availablePatrolUnits + .where((unit) => unit.type.toLowerCase() == 'motorcycle') + .toList(); + } + } + bool validate(GlobalKey formKey) { clearErrors(); @@ -47,8 +331,6 @@ final RxBool isFormValid = RxBool(true); return true; } - - final nrpValidation = TValidators.validateUserInput( 'NRP', nrpController.text, @@ -179,6 +461,11 @@ final RxBool isFormValid = RxBool(true); isFormValid.value = false; } + // Include validation for new patrol unit if creating one + if (isCreatingNewPatrolUnit.value && !validateNewPatrolUnit()) { + isFormValid.value = false; + } + return isFormValid.value; } @@ -196,6 +483,11 @@ final RxBool isFormValid = RxBool(true); qrCodeError.value = ''; bannedReasonError.value = ''; bannedUntilError.value = ''; + + // Clear errors for new patrol unit fields + patrolNameError.value = ''; + patrolTypeError.value = ''; + patrolRadiusError.value = ''; } @override @@ -213,6 +505,11 @@ final RxBool isFormValid = RxBool(true); qrCodeController.dispose(); bannedReasonController.dispose(); bannedUntilController.dispose(); + + // Dispose controllers for new patrol unit + patrolNameController.dispose(); + patrolTypeController.dispose(); + patrolRadiusController.dispose(); super.onClose(); } } diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup/step/selfie-verification/selfie_verification_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup/step/selfie-verification/selfie_verification_controller.dart index a076723..8efb51c 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup/step/selfie-verification/selfie_verification_controller.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup/step/selfie-verification/selfie_verification_controller.dart @@ -1,7 +1,9 @@ import 'dart:developer' as dev; import 'dart:io'; import 'dart:math'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:image_picker/image_picker.dart'; @@ -49,6 +51,15 @@ class SelfieVerificationController extends GetxController { // Development mode options final RxBool bypassLivenessCheck = RxBool(false); final RxBool autoVerifyForDev = RxBool(false); + + // Added a flag to prevent multiple image picker attempts + bool _isImagePickerActive = false; + + // Added counter to track auto-verify attempts + int _autoVerifyAttempts = 0; + + // Add a GlobalKey for rendering a widget to an image (for dev mode) + final GlobalKey _previewContainer = GlobalKey(); // Constructor SelfieVerificationController({this.idCardController}); @@ -94,6 +105,45 @@ class SelfieVerificationController extends GetxController { Future.microtask(() { _initializeAfterDependencies(); }); + + // Add listeners to the dev mode flags to reset state when turned off + ever(bypassLivenessCheck, _handleDevModeChange); + ever(autoVerifyForDev, _handleDevModeChange); + } + + // New method to handle changes in dev mode settings + void _handleDevModeChange(bool isEnabled) { + if (!isEnabled) { + // If dev mode is turned off, reset to initial state + Logger().i('Dev mode disabled, resetting state to initial'); + _resetToInitialState(); + } + } + + // New method to reset everything to initial state when dev mode is turned off + void _resetToInitialState() { + // Reset all states to initial values + clearSelfieImage(); + resetVerificationState(); + _autoVerifyAttempts = 0; + _isImagePickerActive = false; + + // Clean up any temporary image files + _cleanupTemporaryFiles(); + } + + // Method to clean up any temporary image files + Future _cleanupTemporaryFiles() async { + try { + if (selfieImage.value != null && + File(selfieImage.value!.path).existsSync()) { + // Delete the actual file from storage + await File(selfieImage.value!.path).delete(); + Logger().i('Temporary selfie image file deleted'); + } + } catch (e) { + Logger().e('Error cleaning up temporary files: $e'); + } } void _initializeAfterDependencies() { @@ -140,9 +190,13 @@ class SelfieVerificationController extends GetxController { _processCapturedLivenessImage(); } - // Method to perform liveness detection with improved navigation + // Enhanced version of the method to perform liveness detection void performLivenessDetection() async { try { + // Clear any existing verification data first + clearSelfieImage(); + resetVerificationState(); + isPerformingLivenessCheck.value = true; autoStartVerification = true; // Set flag for auto verification @@ -341,12 +395,28 @@ class SelfieVerificationController extends GetxController { } } - // Clear selfie image + // Clear selfie image - Enhanced to ensure proper cleanup void clearSelfieImage() { - selfieImage.value = null; - isSelfieValid.value = false; - hasConfirmedSelfie.value = false; - selfieError.value = ''; + try { + // Delete any existing image file + if (selfieImage.value != null) { + final imagePath = selfieImage.value!.path; + try { + if (File(imagePath).existsSync()) { + File(imagePath).deleteSync(); + Logger().i('Deleted selfie image file: $imagePath'); + } + } catch (e) { + Logger().e('Error deleting selfie image file: $e'); + } + } + } finally { + // Always reset the state values + selfieImage.value = null; + isSelfieValid.value = false; + hasConfirmedSelfie.value = false; + selfieError.value = ''; + } } // Confirm the selfie @@ -365,8 +435,9 @@ class SelfieVerificationController extends GetxController { selfieError.value = ''; } - // Reset verification state + // Reset verification state - update to make it more thorough void resetVerificationState() { + // Reset all related state variables to initial values isVerifyingFace.value = false; isComparingWithIDCard.value = false; isMatchWithIDCard.value = false; @@ -374,59 +445,47 @@ class SelfieVerificationController extends GetxController { faceComparisonResult.value = null; isLivenessCheckPassed.value = false; isPerformingLivenessCheck.value = false; + isUploadingSelfie.value = false; hasConfirmedSelfie.value = false; - autoStartVerification = false; // Reset auto verification flag + autoStartVerification = false; selfieError.value = ''; - } + isSelfieValid.value = false; - // Method to bypass liveness check with a random selfie - Future bypassLivenessCheckWithRandomImage() async { - try { - final logger = Logger(); - logger.i('DEV MODE: Bypassing liveness check with random image'); - - // Start loading state - isPerformingLivenessCheck.value = true; - - // Simulate loading time - await Future.delayed(const Duration(seconds: 1)); - - // Create a temporary file from a bundled asset or generate one - final tempFile = await _createTemporaryImageFile(); - - if (tempFile != null) { - // Set the selfie image - selfieImage.value = XFile(tempFile.path); - - // Set liveness check as passed - isLivenessCheckPassed.value = true; - - // Automatically start verification - autoStartVerification = true; - - // Log the bypass action - logger.i('DEV MODE: Liveness check bypassed successfully'); - - // Start face verification - await Future.delayed(const Duration(seconds: 1)); - await _simulateSuccessfulVerification(); - } else { - logger.e('DEV MODE: Failed to create temporary image file'); - selfieError.value = 'Failed to create dummy selfie image'; - } - } catch (e) { - Logger().e('DEV MODE: Error bypassing liveness check: $e'); - selfieError.value = 'Error bypassing liveness check'; - } finally { - isPerformingLivenessCheck.value = false; + // Ensure the image is fully reset as well + if (selfieImage.value != null) { + clearSelfieImage(); } } - + // Auto-complete verification for development purposes Future autoCompleteVerification() async { try { + // Clear existing data first to ensure fresh state + clearSelfieImage(); + resetVerificationState(); + + // Prevent multiple simultaneous attempts + if (_isImagePickerActive) { + Logger().w( + 'DEV MODE: Image picker already active, skipping auto-verification', + ); + return; + } + + // Check if we've tried too many times + if (_autoVerifyAttempts > 2) { + Logger().w('DEV MODE: Too many auto-verify attempts, using fallback'); + await _simulateVerificationWithoutImage(); + return; + } + + _autoVerifyAttempts++; + _isImagePickerActive = true; + final logger = Logger(); - logger.i('DEV MODE: Auto-completing verification'); + logger.i( + 'DEV MODE: Auto-completing verification (attempt $_autoVerifyAttempts)', + ); // Clear previous states clearSelfieImage(); @@ -435,8 +494,8 @@ class SelfieVerificationController extends GetxController { // Set loading states to show progress isPerformingLivenessCheck.value = true; - // Create a temporary file from assets or generate one - final tempFile = await _createTemporaryImageFile(); + // Create a temporary file using our improved method + final tempFile = await _createDevModeImageFile(); if (tempFile != null) { // Set the selfie image @@ -451,6 +510,7 @@ class SelfieVerificationController extends GetxController { isVerifyingFace.value = true; await Future.delayed(const Duration(milliseconds: 500)); isVerifyingFace.value = false; + isSelfieValid.value = true; // Simulate comparison with ID isComparingWithIDCard.value = true; @@ -483,20 +543,88 @@ class SelfieVerificationController extends GetxController { logger.i('DEV MODE: Auto-verification completed successfully'); } else { - logger.e('DEV MODE: Failed to create temporary image file'); - selfieError.value = 'Failed to create auto-verification image'; - isPerformingLivenessCheck.value = false; + // If we couldn't create an image file, simulate verification without an image + logger.w( + 'DEV MODE: Failed to create temporary image file, using fallback', + ); + await _simulateVerificationWithoutImage(); } } catch (e) { Logger().e('DEV MODE: Error in auto-verification: $e'); selfieError.value = 'Error in auto-verification'; isPerformingLivenessCheck.value = false; + + // Try the fallback approach if we had an error + if (_autoVerifyAttempts <= 3) { + Logger().w('DEV MODE: Auto-verify error, attempting fallback'); + await _simulateVerificationWithoutImage(); + } + } finally { + _isImagePickerActive = false; + } + } + + // New method to simulate verification without needing a real image file + Future _simulateVerificationWithoutImage() async { + try { + // Reset all states first + clearErrors(); + isPerformingLivenessCheck.value = false; + isVerifyingFace.value = false; + isComparingWithIDCard.value = false; + + // Create a colored rectangle image + final tempFile = await _generateColoredRectangleImage(); + + if (tempFile != null) { + // Set as selfie image + selfieImage.value = XFile(tempFile.path); + + // Set all verification flags to success + isLivenessCheckPassed.value = true; + isSelfieValid.value = true; + isMatchWithIDCard.value = true; + matchConfidence.value = 0.99; + + // Create a result object + faceComparisonResult.value = FaceComparisonResult( + sourceFace: FaceModel( + imagePath: tempFile.path, + faceId: 'dev_mode_face_id', + ), + targetFace: FaceModel( + imagePath: 'dev_mode_target', + faceId: 'dev_mode_target_id', + ), + isMatch: true, + confidence: 0.99, + message: 'DEV MODE: Simulated verification', + ); + + // Auto-confirm + hasConfirmedSelfie.value = true; + + Logger().i('DEV MODE: Fallback verification completed successfully'); + } else { + // Last resort - just set the flags without an image + Logger().w( + 'DEV MODE: Could not create even a fallback image, setting flags only', + ); + isLivenessCheckPassed.value = true; + isSelfieValid.value = true; + isMatchWithIDCard.value = true; + hasConfirmedSelfie.value = true; + } + } catch (e) { + Logger().e('DEV MODE: Error in fallback verification: $e'); } } // Helper method to create a temporary image file from assets or generate one - Future _createTemporaryImageFile() async { + Future _createDevModeImageFile() async { try { + Logger().i('DEV MODE: Creating temporary image file'); + // Option 1: Use a bundled asset (if available) try { final ByteData byteData = await rootBundle.load( @@ -505,33 +633,128 @@ class SelfieVerificationController extends GetxController { final Uint8List bytes = byteData.buffer.asUint8List(); final tempDir = await getTemporaryDirectory(); - final tempFile = File('${tempDir.path}/sample_selfie.jpg'); + final tempFile = File( + '${tempDir.path}/dev_sample_selfie_${DateTime.now().millisecondsSinceEpoch}.jpg', + ); await tempFile.writeAsBytes(bytes); + Logger().i('DEV MODE: Created image from asset bundle'); return tempFile; } catch (e) { - // If no bundled asset, fall back to option 2 - Logger().i('No bundled selfie asset found, generating random file'); + // If no bundled asset, fall back to generated image + Logger().i( + 'DEV MODE: No bundled selfie asset found, will generate an image', + ); } - // Option 2: Try to use a device camera image if available - final ImagePicker picker = ImagePicker(); - final XFile? cameraImage = await picker.pickImage( - source: ImageSource.gallery, - imageQuality: 50, - ); + // Option 2: Generate a simple image programmatically + return await _generateColoredRectangleImage(); + } catch (e) { + Logger().e('DEV MODE: Error creating dev mode image file: $e'); + return null; + } + } - if (cameraImage != null) { - return File(cameraImage.path); + // New method to generate a simple colored rectangle as an image + Future _generateColoredRectangleImage() async { + try { + // Create a simple colored rectangle picture + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + final paint = Paint()..color = Colors.blue; + + // Draw a face-like shape + canvas.drawRect(Rect.fromLTWH(0, 0, 200, 200), paint); + + // Draw some face-like features + final facePaint = Paint()..color = Colors.white; + // Eyes + canvas.drawCircle(const Offset(70, 80), 20, facePaint); + canvas.drawCircle(const Offset(130, 80), 20, facePaint); + // Mouth + canvas.drawOval(Rect.fromLTWH(60, 120, 80, 30), facePaint); + + // Convert to image + final picture = recorder.endRecording(); + final img = await picture.toImage(200, 200); + final pngBytes = await img.toByteData(format: ui.ImageByteFormat.png); + + if (pngBytes != null) { + final tempDir = await getTemporaryDirectory(); + final tempFile = File( + '${tempDir.path}/dev_face_${DateTime.now().millisecondsSinceEpoch}.png', + ); + await tempFile.writeAsBytes(pngBytes.buffer.asUint8List()); + + Logger().i('DEV MODE: Generated colored rectangle image successfully'); + return tempFile; } - - // If both fail, return null and handle in calling method + return null; } catch (e) { - Logger().e('Error creating temporary image file: $e'); + Logger().e('DEV MODE: Error generating colored rectangle: $e'); return null; } } + // Method to bypass liveness check with a generated selfie + Future bypassLivenessCheckWithRandomImage() async { + try { + // Clear existing data first + clearSelfieImage(); + resetVerificationState(); + + // Avoid multiple simultaneous attempts + if (_isImagePickerActive) { + Logger().w('DEV MODE: Image picker already active, skipping bypass'); + return; + } + + _isImagePickerActive = true; + final logger = Logger(); + logger.i('DEV MODE: Bypassing liveness check with generated image'); + + // Start loading state + isPerformingLivenessCheck.value = true; + + // Simulate loading time + await Future.delayed(const Duration(seconds: 1)); + + // Create a temporary file using dev mode method + final tempFile = await _createDevModeImageFile(); + + if (tempFile != null) { + // Set the selfie image + selfieImage.value = XFile(tempFile.path); + + // Set liveness check as passed + isLivenessCheckPassed.value = true; + + // Automatically start verification + autoStartVerification = true; + + // Log the bypass action + logger.i('DEV MODE: Liveness check bypassed successfully'); + + // Start face verification + await Future.delayed(const Duration(seconds: 1)); + await _simulateSuccessfulVerification(); + } else { + // Try the fallback approach + logger.w('DEV MODE: Failed with primary method, trying fallback'); + await _simulateVerificationWithoutImage(); + } + } catch (e) { + Logger().e('DEV MODE: Error bypassing liveness check: $e'); + selfieError.value = 'Error bypassing liveness check'; + + // Try the fallback approach + await _simulateVerificationWithoutImage(); + } finally { + isPerformingLivenessCheck.value = false; + _isImagePickerActive = false; + } + } + // Simulate successful verification for development purposes Future _simulateSuccessfulVerification() async { try { diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/main/registraion_form_screen.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/main/registraion_form_screen.dart index 58a43d6..651a9e0 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/main/registraion_form_screen.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/main/registraion_form_screen.dart @@ -5,7 +5,6 @@ import 'package:sigap/src/features/auth/presentasion/controllers/signup/main/reg import 'package:sigap/src/features/auth/presentasion/pages/signup/step/id-card-verification/id_card_verification_step.dart'; import 'package:sigap/src/features/auth/presentasion/pages/signup/step/identity-verification/identity_verification_step.dart'; import 'package:sigap/src/features/auth/presentasion/pages/signup/step/officer-information/officer_info_step.dart'; -import 'package:sigap/src/features/auth/presentasion/pages/signup/step/officer-information/unit_info_step.dart'; import 'package:sigap/src/features/auth/presentasion/pages/signup/step/personal-information/personal_info_step.dart'; import 'package:sigap/src/features/auth/presentasion/pages/signup/step/selfie-verification/selfie_verification_step.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart'; @@ -167,12 +166,6 @@ class FormRegistrationScreen extends StatelessWidget { return isOfficer ? const OfficerInfoStep() : const IdentityVerificationStep(); - case 4: - // This step only exists for officers - if (isOfficer) { - return const UnitInfoStep(); - } - return const SizedBox.shrink(); default: return const SizedBox.shrink(); } diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/id-card-verification/id_card_verification_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/id-card-verification/id_card_verification_step.dart index 2418c26..233b5b1 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/id-card-verification/id_card_verification_step.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/id-card-verification/id_card_verification_step.dart @@ -187,10 +187,7 @@ class IdCardVerificationStep extends StatelessWidget { "Your photo and all text should be clearly visible", "Avoid using flash to prevent glare", ], - backgroundColor: TColors.primary.withOpacity(0.1), - textColor: TColors.primary, - iconColor: TColors.primary, - borderColor: TColors.primary.withOpacity(0.3), + ); } diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/identity-verification/widgets/face_verification_section.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/identity-verification/widgets/face_verification_section.dart index 2f2bcdb..896576f 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/identity-verification/widgets/face_verification_section.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/identity-verification/widgets/face_verification_section.dart @@ -76,11 +76,7 @@ class FaceVerificationSection extends StatelessWidget { 'Remove glasses and face coverings', 'Face the camera directly without tilting your head', ], - backgroundColor: TColors.primary.withOpacity(0.1), - textColor: TColors.primary, - iconColor: TColors.primary, - leadingIcon: Icons.face, - borderColor: TColors.primary.withOpacity(0.3), + ); } diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/officer-information/officer_info_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/officer-information/officer_info_step.dart index 5e51f53..28283d0 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/officer-information/officer_info_step.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/officer-information/officer_info_step.dart @@ -2,8 +2,10 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/main/registration_form_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/officer-information/officer_info_controller.dart'; +import 'package:sigap/src/features/auth/presentasion/pages/signup/step/officer-information/patrol_unit_selection_screen.dart'; import 'package:sigap/src/shared/widgets/form/form_section_header.dart'; import 'package:sigap/src/shared/widgets/text/custom_text_field.dart'; +import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/sizes.dart'; import 'package:sigap/src/utils/validators/validation.dart'; @@ -29,100 +31,574 @@ class OfficerInfoStep extends StatelessWidget { const SizedBox(height: TSizes.spaceBtwItems), - // Unit ID field - Obx( - () => CustomTextField( - label: 'Unit ID', - controller: controller.unitIdController, - validator: (v) => TValidators.validateUserInput('Unit ID', v, 20), - errorText: controller.unitIdError.value, - textInputAction: TextInputAction.next, - hintText: 'e.g., POLRES01', - onChanged: (value) { - controller.unitIdController.text = value; - controller.unitIdError.value = ''; - }, - ), - ), - - // Patrol Unit ID field - Obx( - () => CustomTextField( - label: 'Patrol Unit ID', - controller: controller.patrolUnitIdController, - validator: - (v) => TValidators.validateUserInput( - 'Patrol Unit ID', - v, - 100, - required: true, - ), - errorText: controller.patrolUnitIdError.value, - textInputAction: TextInputAction.next, - hintText: 'e.g., PATROL01', - onChanged: (value) { - controller.patrolUnitIdController.text = value; - controller.patrolUnitIdError.value = ''; - }, - ), - ), - // NRP field + CustomTextField( + label: 'NRP', + controller: controller.nrpController, + validator: TValidators.validateNRP, + errorText: controller.nrpError.value, + textInputAction: TextInputAction.next, + keyboardType: TextInputType.number, + hintText: 'e.g., 123456789', + onChanged: (value) { + controller.nrpController.text = value; + controller.nrpError.value = ''; + }, + ), Obx( - () => CustomTextField( - label: 'NRP', - controller: controller.nrpController, - validator: TValidators.validateNRP, - errorText: controller.nrpError.value, - textInputAction: TextInputAction.next, - keyboardType: TextInputType.number, - hintText: 'e.g., 123456789', - onChanged: (value) { - controller.nrpController.text = value; - controller.nrpError.value = ''; - }, - ), + () => + controller.nrpError.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + controller.nrpError.value, + style: TextStyle(color: Colors.red[700], fontSize: 12), + ), + ) + : const SizedBox.shrink(), ), // Rank field + CustomTextField( + label: 'Rank', + controller: controller.rankController, + validator: TValidators.validateRank, + errorText: controller.rankError.value, + textInputAction: TextInputAction.next, + hintText: 'e.g., Captain', + onChanged: (value) { + controller.rankController.text = value; + controller.rankError.value = ''; + }, + ), Obx( - () => CustomTextField( - label: 'Rank', - controller: controller.rankController, - validator: TValidators.validateRank, - errorText: controller.rankError.value, - textInputAction: TextInputAction.next, - hintText: 'e.g., Captain', - onChanged: (value) { - controller.rankController.text = value; - controller.rankError.value = ''; - }, - ), + () => + controller.rankError.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + controller.rankError.value, + style: TextStyle(color: Colors.red[700], fontSize: 12), + ), + ) + : const SizedBox.shrink(), ), // Position field + CustomTextField( + label: 'Position', + controller: controller.positionController, + validator: + (v) => TValidators.validateUserInput( + 'Position', + v, + 100, + required: true, + ), + errorText: controller.positionError.value, + textInputAction: TextInputAction.next, + hintText: 'e.g., Head of Unit', + onChanged: (value) { + controller.positionController.text = value; + controller.positionError.value = ''; + }, + ), Obx( - () => CustomTextField( - label: 'Position', - controller: controller.positionController, - validator: - (v) => TValidators.validateUserInput( - 'Position', - v, - 100, - required: true, - ), - errorText: controller.positionError.value, - textInputAction: TextInputAction.done, - hintText: 'e.g., Head of Unit', - onChanged: (value) { - controller.positionController.text = value; - controller.positionError.value = ''; - }, - ), + () => + controller.positionError.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + controller.positionError.value, + style: TextStyle(color: Colors.red[700], fontSize: 12), + ), + ) + : const SizedBox.shrink(), + ), + + const SizedBox(height: TSizes.spaceBtwSections), + + // Unit Selection Section + const FormSectionHeader( + title: 'Unit Selection', + subtitle: 'Select your police unit', + ), + + const SizedBox(height: TSizes.spaceBtwItems), + + // Unit dropdown + _buildUnitDropdown(controller), + + const SizedBox(height: TSizes.spaceBtwSections), + + // Patrol Unit Section - Simplified to show only current selection and a button to navigate + const FormSectionHeader( + title: 'Patrol Unit', + subtitle: 'Select or create your patrol unit', + ), + + const SizedBox(height: TSizes.spaceBtwItems), + + // Display selected patrol unit (if any) + Builder( + builder: (context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final theme = Theme.of(context); + + return GetX( + builder: + (controller) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (controller.selectedPatrolUnitName.isNotEmpty) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.primaryColor.withOpacity( + isDark ? 0.2 : 0.1, + ), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.primaryColor), + ), + child: Row( + children: [ + Icon( + controller.selectedPatrolType.value == + PatrolUnitType.car + ? Icons.directions_car + : Icons.motorcycle, + color: theme.primaryColor, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + 'Selected Patrol Unit', + style: TextStyle( + color: + isDark + ? Colors.grey[400] + : Colors.grey[600], + fontSize: 12, + ), + ), + Text( + controller.selectedPatrolUnitName.value, + style: TextStyle( + fontWeight: FontWeight.bold, + color: + isDark + ? Colors.white + : Colors.black87, + ), + ), + ], + ), + ), + ], + ), + ), + + const SizedBox(height: 16), + + // Configure Patrol Unit button + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: + () => _navigateToPatrolUnitSelectionScreen( + controller, + ), + style: ElevatedButton.styleFrom( + backgroundColor: theme.primaryColor, + foregroundColor: + isDark ? Colors.black : Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + icon: Icon( + controller.patrolUnitIdController.text.isEmpty + ? Icons.add_circle_outline + : Icons.edit, + ), + label: Text( + controller.patrolUnitIdController.text.isEmpty + ? 'Configure Patrol Unit' + : 'Change Patrol Unit', + ), + ), + ), + + Obx( + () => + controller.patrolUnitIdError.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + controller.patrolUnitIdError.value, + style: TextStyle( + color: TColors.error, + fontSize: 12, + ), + ), + ) + : const SizedBox.shrink(), + ), + ], + ), + ); + }, ), ], ), ); } + + // Navigate to the patrol unit selection screen + void _navigateToPatrolUnitSelectionScreen(OfficerInfoController controller) { + // Check if a unit is selected first + if (controller.unitIdController.text.isEmpty) { + Get.snackbar( + 'Unit Required', + 'Please select a unit before configuring patrol unit', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red.withOpacity(0.1), + colorText: Colors.red, + ); + return; + } + + Get.to(() => PatrolUnitSelectionScreen()); + } + + // Build unit dropdown selection + Widget _buildUnitDropdown(OfficerInfoController controller) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Label - using context directly + Builder( + builder: (context) { + return Text( + 'Select Unit:', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), + ); + }, + ), + + const SizedBox(height: TSizes.sm), + + // Dropdown using Builder to access current context (and theme) + Builder( + builder: (context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final theme = Theme.of(context); + + // Use custom text field styling for consistency + final borderRadius = BorderRadius.circular(TSizes.inputFieldRadius); + final fillColor = isDark ? TColors.dark : TColors.lightContainer; + + return GetX( + builder: (controller) { + if (controller.isLoadingUnits.value) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: TSizes.md, + vertical: TSizes.md, + ), + decoration: BoxDecoration( + border: Border.all(color: theme.dividerColor), + borderRadius: borderRadius, + color: fillColor, + ), + child: Center( + child: Padding( + padding: const EdgeInsets.all(TSizes.sm), + child: CircularProgressIndicator( + color: theme.primaryColor, + ), + ), + ), + ); + } + + if (controller.availableUnits.isEmpty) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: TSizes.md, + vertical: TSizes.md, + ), + decoration: BoxDecoration( + border: Border.all(color: theme.dividerColor), + borderRadius: borderRadius, + color: fillColor, + ), + child: Text( + 'No units available', + style: theme.textTheme.bodyMedium?.copyWith( + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + ); + } + + // Get the selected unit (if any) + final selectedUnit = controller.availableUnits.firstWhereOrNull( + (unit) => unit.codeUnit == controller.unitIdController.text, + ); + + return Column( + children: [ + // Dropdown Selection Button + GestureDetector( + onTap: () { + // Toggle dropdown visibility + controller.isUnitDropdownOpen.toggle(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: TSizes.md, + vertical: TSizes.md, + ), + decoration: BoxDecoration( + border: Border.all( + color: + controller.isUnitDropdownOpen.value + ? theme.primaryColor + : theme.dividerColor, + width: + controller.isUnitDropdownOpen.value ? 1.5 : 1, + ), + borderRadius: borderRadius, + color: fillColor, + ), + child: Row( + children: [ + Expanded( + child: Text( + selectedUnit != null + ? '${selectedUnit.name} (${selectedUnit.type.name})' + : 'Select Unit', + style: theme.textTheme.bodyMedium?.copyWith( + color: + selectedUnit != null + ? theme.textTheme.bodyMedium?.color + : (isDark + ? Colors.grey[400] + : Colors.grey[600]), + ), + ), + ), + Icon( + controller.isUnitDropdownOpen.value + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down, + color: + controller.isUnitDropdownOpen.value + ? theme.primaryColor + : (isDark + ? Colors.grey[400] + : Colors.grey[600]), + ), + ], + ), + ), + ), + + // Dropdown Options + if (controller.isUnitDropdownOpen.value) + Container( + margin: const EdgeInsets.only(top: 4), + decoration: BoxDecoration( + color: fillColor, + borderRadius: borderRadius, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity( + isDark ? 0.3 : 0.1, + ), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + border: Border.all(color: theme.dividerColor), + ), + constraints: const BoxConstraints(maxHeight: 250), + child: ClipRRect( + borderRadius: borderRadius, + child: ListView.builder( + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: controller.availableUnits.length, + itemBuilder: (context, index) { + final unit = controller.availableUnits[index]; + final isSelected = + unit.codeUnit == + controller.unitIdController.text; + + return GestureDetector( + onTap: () { + controller.onUnitSelected(unit); + controller.isUnitDropdownOpen.value = false; + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: TSizes.md, + vertical: TSizes.md - 2, + ), + decoration: BoxDecoration( + color: + isSelected + ? theme.primaryColor.withOpacity( + isDark ? 0.2 : 0.1, + ) + : Colors.transparent, + border: + index < + controller + .availableUnits + .length - + 1 + ? Border( + bottom: BorderSide( + color: theme.dividerColor + .withOpacity(0.5), + width: 0.5, + ), + ) + : null, + ), + child: Row( + children: [ + // Unit Icon + Icon( + isSelected + ? Icons.shield + : Icons.shield_outlined, + color: + isSelected + ? theme.primaryColor + : (isDark + ? Colors.grey[400] + : Colors.grey[600]), + size: 20, + ), + const SizedBox(width: 12), + + // Unit Name + Expanded( + child: Text( + '${unit.name} (${unit.type.name})', + style: theme.textTheme.bodyMedium + ?.copyWith( + color: + isSelected + ? theme.primaryColor + : theme + .textTheme + .bodyMedium + ?.color, + fontWeight: + isSelected + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ), + + // Checkmark for selected item + if (isSelected) + Icon( + Icons.check, + color: theme.primaryColor, + size: 18, + ), + ], + ), + ), + ); + }, + ), + ), + ), + + // Selected unit display + if (selectedUnit != null && + !controller.isUnitDropdownOpen.value) + Container( + margin: const EdgeInsets.only( + top: TSizes.spaceBtwInputFields, + ), + padding: const EdgeInsets.all(TSizes.md), + decoration: BoxDecoration( + color: theme.primaryColor.withOpacity( + isDark ? 0.2 : 0.1, + ), + borderRadius: borderRadius, + border: Border.all(color: theme.primaryColor), + ), + child: Row( + children: [ + Icon( + Icons.shield_outlined, + color: theme.primaryColor, + ), + const SizedBox(width: TSizes.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Selected Unit', + style: theme.textTheme.labelSmall?.copyWith( + color: + isDark + ? Colors.grey[400] + : Colors.grey[600], + ), + ), + Text( + '${selectedUnit.name} (${selectedUnit.type.name})', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + Icon( + Icons.check_circle, + color: theme.primaryColor, + size: 20, + ), + ], + ), + ), + + // Error message + if (controller.unitIdError.value.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + controller.unitIdError.value, + style: TextStyle(color: TColors.error, fontSize: 12), + ), + ), + + if (selectedUnit == null || + controller.unitIdError.value.isEmpty) + const SizedBox(height: TSizes.spaceBtwInputFields), + ], + ); + }, + ); + }, + ), + ], + ); + } } diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/officer-information/patrol_unit_selection_screen.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/officer-information/patrol_unit_selection_screen.dart new file mode 100644 index 0000000..591907b --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/officer-information/patrol_unit_selection_screen.dart @@ -0,0 +1,485 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/officer-information/officer_info_controller.dart'; +import 'package:sigap/src/shared/widgets/text/custom_text_field.dart'; +import 'package:sigap/src/utils/constants/colors.dart'; +import 'package:sigap/src/utils/constants/sizes.dart'; +import 'package:sigap/src/utils/validators/validation.dart'; + +class PatrolUnitSelectionScreen extends StatelessWidget { + PatrolUnitSelectionScreen({super.key}); + + final controller = Get.find(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Configure Patrol Unit'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Get.back(), + ), + ), + body: Padding( + padding: const EdgeInsets.all(TSizes.defaultSpace), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Unit info display + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.business, color: TColors.primary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Selected Unit', + style: TextStyle( + color: Colors.grey[600], + fontSize: 12, + ), + ), + Obx( + () => Text( + controller.selectedUnitName.value, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + ], + ), + ), + + const SizedBox(height: TSizes.spaceBtwSections), + + // Patrol Type Selection + const Text( + 'Patrol Type:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + const SizedBox(height: TSizes.spaceBtwInputFields / 2), + GetX( + builder: + (controller) => Row( + children: [ + Expanded( + child: InkWell( + onTap: () { + controller.setPatrolUnitType(PatrolUnitType.car); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: + controller.selectedPatrolType.value == + PatrolUnitType.car + ? TColors.primary.withOpacity(0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + controller.selectedPatrolType.value == + PatrolUnitType.car + ? TColors.primary + : Colors.grey, + ), + ), + child: Column( + children: [ + Icon( + Icons.directions_car, + color: + controller.selectedPatrolType.value == + PatrolUnitType.car + ? TColors.primary + : Colors.grey, + size: 32, + ), + const SizedBox(height: TSizes.sm), + const Text('Car'), + ], + ), + ), + ), + ), + const SizedBox(width: TSizes.spaceBtwInputFields), + Expanded( + child: InkWell( + onTap: () { + controller.setPatrolUnitType( + PatrolUnitType.motorcycle, + ); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: + controller.selectedPatrolType.value == + PatrolUnitType.motorcycle + ? TColors.primary.withOpacity(0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + controller.selectedPatrolType.value == + PatrolUnitType.motorcycle + ? TColors.primary + : Colors.grey, + ), + ), + child: Column( + children: [ + Icon( + Icons.motorcycle, + color: + controller.selectedPatrolType.value == + PatrolUnitType.motorcycle + ? TColors.primary + : Colors.grey, + size: 32, + ), + const SizedBox(height: TSizes.sm), + const Text('Motorcycle'), + ], + ), + ), + ), + ), + ], + ), + ), + + const SizedBox(height: TSizes.spaceBtwSections), + + // Selection Mode Tabs + const Text( + 'Select option:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + const SizedBox(height: TSizes.spaceBtwInputFields / 2), + GetX( + builder: + (controller) => Row( + children: [ + _buildModeTab( + controller, + PatrolSelectionMode.individual, + 'Individual', + Icons.person, + ), + _buildModeTab( + controller, + PatrolSelectionMode.group, + 'Group', + Icons.group, + ), + _buildModeTab( + controller, + PatrolSelectionMode.createNew, + 'Create New', + Icons.add_circle, + ), + ], + ), + ), + + const SizedBox(height: TSizes.spaceBtwInputFields), + + // Patrol Unit Selection/Creation based on mode + Expanded( + child: GetX( + builder: (controller) { + switch (controller.patrolSelectionMode.value) { + case PatrolSelectionMode.individual: + case PatrolSelectionMode.group: + return _buildExistingPatrolUnitSelection(controller); + case PatrolSelectionMode.createNew: + return _buildCreatePatrolUnitForm(controller); + } + }, + ), + ), + + // Confirm button + Padding( + padding: const EdgeInsets.symmetric( + vertical: TSizes.defaultSpace, + ), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + if (controller.patrolSelectionMode.value == + PatrolSelectionMode.createNew) { + controller.createNewPatrolUnit().then((success) { + if (success) { + Get.back(); + } + }); + } else if (controller + .patrolUnitIdController + .text + .isNotEmpty) { + // Selection mode - just go back if a patrol unit is selected + Get.back(); + } else { + Get.snackbar( + 'Selection Required', + 'Please select a patrol unit or create a new one', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red.withOpacity(0.1), + colorText: Colors.red, + ); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: TColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: const Text('Confirm Selection'), + ), + ), + ), + ], + ), + ), + ); + } + + // Helper method to build a selection mode tab + Widget _buildModeTab( + OfficerInfoController controller, + PatrolSelectionMode mode, + String label, + IconData icon, + ) { + return Expanded( + child: InkWell( + onTap: () => controller.setPatrolSelectionMode(mode), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: + controller.patrolSelectionMode.value == mode + ? TColors.primary.withOpacity(0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + controller.patrolSelectionMode.value == mode + ? TColors.primary + : Colors.grey, + ), + ), + child: Column( + children: [ + Icon( + icon, + color: + controller.patrolSelectionMode.value == mode + ? TColors.primary + : Colors.grey, + ), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + color: + controller.patrolSelectionMode.value == mode + ? TColors.primary + : Colors.grey, + ), + ), + ], + ), + ), + ), + ); + } + + // Build existing patrol unit selection list + Widget _buildExistingPatrolUnitSelection(OfficerInfoController controller) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Select Patrol Unit:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + const SizedBox(height: TSizes.spaceBtwInputFields / 2), + GetX( + builder: (controller) { + if (controller.isLoadingPatrolUnits.value) { + return const Expanded( + child: Center(child: CircularProgressIndicator()), + ); + } + + if (controller.unitIdController.text.isEmpty) { + return const Expanded( + child: Center(child: Text('Please select a unit first')), + ); + } + + final filteredUnits = controller.getFilteredPatrolUnits(); + + if (filteredUnits.isEmpty) { + return const Expanded( + child: Center( + child: Text('No patrol units available for this type'), + ), + ); + } + + return Expanded( + child: ListView.builder( + itemCount: filteredUnits.length, + itemBuilder: (context, index) { + final patrolUnit = filteredUnits[index]; + final isSelected = + patrolUnit.id == controller.patrolUnitIdController.text; + + return Card( + elevation: isSelected ? 2 : 0, + color: isSelected ? TColors.primary.withOpacity(0.1) : null, + margin: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + title: Text( + patrolUnit.name, + style: TextStyle( + fontWeight: + isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + subtitle: Text( + 'Members: ${patrolUnit.members?.length ?? 0}', + ), + leading: Icon( + patrolUnit.type.toLowerCase() == 'car' + ? Icons.directions_car + : Icons.motorcycle, + color: isSelected ? TColors.primary : null, + ), + trailing: + isSelected + ? const Icon( + Icons.check_circle, + color: TColors.primary, + ) + : null, + selected: isSelected, + onTap: () => controller.joinPatrolUnit(patrolUnit), + ), + ); + }, + ), + ); + }, + ), + ], + ); + } + + // Build create new patrol unit form + Widget _buildCreatePatrolUnitForm(OfficerInfoController controller) { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Create New Patrol Unit:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + const SizedBox(height: TSizes.spaceBtwInputFields), + + // Patrol Name Field + CustomTextField( + label: 'Patrol Unit Name', + controller: controller.patrolNameController, + validator: + (v) => TValidators.validateUserInput('Patrol Unit Name', v, 30), + errorText: controller.patrolNameError.value, + textInputAction: TextInputAction.next, + hintText: 'e.g., Alpha Team', + onChanged: (value) { + controller.patrolNameController.text = value; + controller.patrolNameError.value = ''; + }, + ), + GetX( + builder: + (controller) => + controller.patrolNameError.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + controller.patrolNameError.value, + style: TextStyle( + color: Colors.red[700], + fontSize: 12, + ), + ), + ) + : const SizedBox.shrink(), + ), + + // Patrol Radius Field + CustomTextField( + label: 'Patrol Radius (in meters)', + controller: controller.patrolRadiusController, + validator: (v) { + if (v == null || v.isEmpty) { + return 'Radius is required'; + } + try { + final radius = double.parse(v); + if (radius <= 0) { + return 'Radius must be greater than 0'; + } + } catch (e) { + return 'Please enter a valid number'; + } + return null; + }, + errorText: controller.patrolRadiusError.value, + textInputAction: TextInputAction.done, + keyboardType: TextInputType.number, + hintText: 'e.g., 500', + onChanged: (value) { + controller.patrolRadiusController.text = value; + controller.patrolRadiusError.value = ''; + }, + ), + GetX( + builder: + (controller) => + controller.patrolRadiusError.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + controller.patrolRadiusError.value, + style: TextStyle( + color: Colors.red[700], + fontSize: 12, + ), + ), + ) + : const SizedBox.shrink(), + ), + ], + ), + ); + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/selfie-verification/selfie_verification_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/selfie-verification/selfie_verification_step.dart index ee80963..027f5e0 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/selfie-verification/selfie_verification_step.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/selfie-verification/selfie_verification_step.dart @@ -10,6 +10,7 @@ import 'package:sigap/src/shared/widgets/verification/validation_message_card.da import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/sizes.dart'; import 'package:sigap/src/utils/helpers/helper_functions.dart'; +import 'package:sigap/src/utils/popups/loaders.dart'; // Enum untuk tracking status verifikasi enum VerificationStatus { @@ -33,6 +34,8 @@ class SelfieVerificationStep extends StatelessWidget { final mainController = Get.find(); final facialVerificationService = FacialVerificationService.instance; + final isDark = THelperFunctions.isDarkMode(context); + // Check if we need to update the skip verification flag from arguments final dynamic args = Get.arguments; if (args != null && @@ -74,27 +77,29 @@ class SelfieVerificationStep extends StatelessWidget { BuildContext context = Get.context!; final controller = Get.find(); + final isDark = THelperFunctions.isDarkMode(context); + final warningColor = isDark ? Colors.amber : TColors.warning; return Container( margin: const EdgeInsets.only(bottom: TSizes.spaceBtwItems), padding: const EdgeInsets.all(TSizes.sm), decoration: BoxDecoration( - color: Colors.amber.withOpacity(0.1), + color: warningColor.withOpacity(0.1), borderRadius: BorderRadius.circular(TSizes.borderRadiusSm), - border: Border.all(color: Colors.amber), + border: Border.all(color: warningColor), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - const Icon(Icons.code, color: Colors.amber, size: TSizes.iconSm), + Icon(Icons.code, color: warningColor, size: TSizes.iconSm), const SizedBox(width: TSizes.xs), Expanded( child: Text( 'Development Mode', style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: Colors.amber, + color: warningColor, fontWeight: FontWeight.bold, ), ), @@ -111,7 +116,7 @@ class SelfieVerificationStep extends StatelessWidget { 'Bypass liveness check', style: Theme.of( context, - ).textTheme.labelSmall?.copyWith(color: Colors.amber), + ).textTheme.labelSmall?.copyWith(color: warningColor), ), ), Obx( @@ -120,23 +125,26 @@ class SelfieVerificationStep extends StatelessWidget { onChanged: (value) { controller.bypassLivenessCheck.value = value; if (value) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Liveness check will be bypassed'), - backgroundColor: Colors.orange, - duration: Duration(seconds: 2), - ), + TLoaders.errorSnackBar( + title: 'Bypass Enabled', + message: 'Liveness check will be skipped', + ); + } else { + // When turning off, show a notification + TLoaders.infoSnackBar( + title: 'Bypass Disabled', + message: 'Liveness check will be performed', ); } }, - activeColor: Colors.amber, - activeTrackColor: Colors.amber.withOpacity(0.5), + activeColor: warningColor, + activeTrackColor: warningColor.withOpacity(0.5), ), ), ], ), - // Auto-verify toggle (new) + // Auto-verify toggle (with updated toggle handler) Row( children: [ Expanded( @@ -144,7 +152,7 @@ class SelfieVerificationStep extends StatelessWidget { 'Auto-verify (auto-pass all checks)', style: Theme.of( context, - ).textTheme.labelSmall?.copyWith(color: Colors.amber), + ).textTheme.labelSmall?.copyWith(color: warningColor), ), ), Obx( @@ -153,14 +161,9 @@ class SelfieVerificationStep extends StatelessWidget { onChanged: (value) { controller.autoVerifyForDev.value = value; if (value) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'All verification steps will be auto-passed', - ), - backgroundColor: Colors.deepOrange, - duration: Duration(seconds: 2), - ), + TLoaders.errorSnackBar( + title: 'Auto-verify Enabled', + message: 'All checks will be auto-passed', ); // If auto-verify is enabled, also enable bypass @@ -174,10 +177,17 @@ class SelfieVerificationStep extends StatelessWidget { controller.autoCompleteVerification(); }); } + } else { + // When turning off auto-verify, show notification + TLoaders.infoSnackBar( + title: 'Auto-verify Disabled', + message: + 'You will need to complete all checks manually', + ); } }, - activeColor: Colors.deepOrange, - activeTrackColor: Colors.deepOrange.withOpacity(0.5), + activeColor: TColors.error, + activeTrackColor: TColors.error.withOpacity(0.5), ), ), ], @@ -187,7 +197,7 @@ class SelfieVerificationStep extends StatelessWidget { Text( 'Warning: Only use in development environment!', style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Colors.red.shade300, + color: TColors.error, fontStyle: FontStyle.italic, ), ), @@ -384,7 +394,7 @@ class SelfieVerificationStep extends StatelessWidget { Color _getTextColor(bool isCompleted, bool isActive, bool isDark) { if (isCompleted || isActive) { - return TColors.primary; + return isDark ? TColors.light : TColors.primary; } return isDark ? TColors.grey : Colors.grey.shade600; @@ -478,6 +488,13 @@ class SelfieVerificationStep extends StatelessWidget { }); } + final devAccentColor = TColors.error; + final warningColor = isDark ? Colors.amber : TColors.warning; + final bgColor = isDark ? TColors.darkerGrey : TColors.light; + final borderColor = isDark ? TColors.darkGrey : TColors.grey; + final textColor = isDark ? TColors.light : TColors.dark; + final secondaryTextColor = isDark ? TColors.lightGrey : TColors.darkGrey; + return Column( children: [ // Add auto-verify badge when enabled @@ -491,8 +508,8 @@ class SelfieVerificationStep extends StatelessWidget { horizontal: TSizes.sm, ), decoration: BoxDecoration( - color: Colors.deepOrange.withOpacity(0.1), - border: Border.all(color: Colors.deepOrange.withOpacity(0.5)), + color: devAccentColor.withOpacity(0.1), + border: Border.all(color: devAccentColor.withOpacity(0.5)), borderRadius: BorderRadius.circular(TSizes.borderRadiusSm), ), child: Row( @@ -501,13 +518,13 @@ class SelfieVerificationStep extends StatelessWidget { Icon( Icons.developer_mode, size: TSizes.iconSm, - color: Colors.deepOrange, + color: devAccentColor, ), const SizedBox(width: TSizes.xs), Text( 'Auto-Verification Enabled', style: TextStyle( - color: Colors.deepOrange, + color: devAccentColor, fontWeight: FontWeight.bold, fontSize: 12, ), @@ -521,12 +538,9 @@ class SelfieVerificationStep extends StatelessWidget { width: double.infinity, height: 200, decoration: BoxDecoration( - color: isDark ? Colors.grey.shade900 : Colors.grey.shade50, + color: bgColor, borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), - border: Border.all( - color: isDark ? Colors.grey.shade800 : Colors.grey.shade300, - width: 2, - ), + border: Border.all(color: borderColor, width: 2), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -534,7 +548,7 @@ class SelfieVerificationStep extends StatelessWidget { Icon( Icons.face_retouching_natural, size: 60, - color: Colors.grey.shade400, + color: borderColor, ), const SizedBox(height: TSizes.md), Text( @@ -542,13 +556,13 @@ class SelfieVerificationStep extends StatelessWidget { style: TextStyle( fontSize: 16, fontWeight: FontWeight.w500, - color: Colors.grey.shade600, + color: textColor, ), ), const SizedBox(height: TSizes.sm), Text( 'Tap the button below to start', - style: TextStyle(fontSize: 14, color: Colors.grey.shade500), + style: TextStyle(fontSize: 14, color: secondaryTextColor), ), ], ), @@ -563,7 +577,7 @@ class SelfieVerificationStep extends StatelessWidget { // Clear any previous images or states before starting new verification controller.clearSelfieImage(); controller.resetVerificationState(); - + // Use bypass if enabled if (facialVerificationService.skipFaceVerification && controller.bypassLivenessCheck.value) { @@ -584,7 +598,7 @@ class SelfieVerificationStep extends StatelessWidget { ), ), ), - + // Show bypass button if development mode is enabled if (facialVerificationService.skipFaceVerification) Obx(() { @@ -600,19 +614,19 @@ class SelfieVerificationStep extends StatelessWidget { }, icon: Icon( Icons.developer_mode, - color: Colors.amber, + color: warningColor, size: TSizes.iconSm, ), label: Text( 'DEV: Use Random Selfie & Skip Verification', - style: TextStyle(color: Colors.amber), + style: TextStyle(color: warningColor), ), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: TSizes.md, vertical: TSizes.xs, ), - backgroundColor: Colors.amber.withOpacity(0.1), + backgroundColor: warningColor.withOpacity(0.1), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( TSizes.borderRadiusSm, @@ -635,8 +649,8 @@ class SelfieVerificationStep extends StatelessWidget { icon: const Icon(Icons.skip_next), label: const Text('DEV: Skip & Auto-Complete Verification'), style: ElevatedButton.styleFrom( - backgroundColor: Colors.deepOrange, - foregroundColor: Colors.white, + backgroundColor: devAccentColor, + foregroundColor: TColors.white, minimumSize: const Size(double.infinity, 45), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(TSizes.buttonRadius), @@ -648,15 +662,20 @@ class SelfieVerificationStep extends StatelessWidget { } Widget _buildLivenessState(SelfieVerificationController controller) { - final isDark = THelperFunctions.isDarkMode(Get.context!); + BuildContext context = Get.context!; + final isDark = THelperFunctions.isDarkMode(context); + final bgColor = isDark ? TColors.darkerGrey : TColors.light; + final borderColor = isDark ? TColors.darkGrey : TColors.grey; + final secondaryTextColor = isDark ? TColors.lightGrey : TColors.darkGrey; + return Container( width: double.infinity, height: 200, decoration: BoxDecoration( - color: isDark ? Colors.grey.shade900 : Colors.grey.shade50, + color: bgColor, borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), border: Border.all( - color: isDark ? Colors.grey.shade800 : Colors.grey.shade300, + color: borderColor, width: 2, ), ), @@ -683,7 +702,7 @@ class SelfieVerificationStep extends StatelessWidget { const SizedBox(height: TSizes.sm), Text( 'Please follow the on-screen instructions', - style: TextStyle(fontSize: 14, color: Colors.grey.shade600), + style: TextStyle(fontSize: 14, color: secondaryTextColor), ), ], ), @@ -755,7 +774,7 @@ class SelfieVerificationStep extends StatelessWidget { isLoading: true, title: 'Detecting Face', icon: Icons.face_retouching_natural, - customColor: Colors.blue, + customColor: TColors.primary, ); case VerificationStatus.comparingWithID: @@ -765,7 +784,7 @@ class SelfieVerificationStep extends StatelessWidget { isLoading: true, title: 'Face Matching', icon: Icons.compare, - customColor: Colors.blue, + customColor: TColors.primary, ); case VerificationStatus.livenessCompleted: @@ -870,10 +889,7 @@ class SelfieVerificationStep extends StatelessWidget { 'Ensure your entire face is visible', 'Avoid shadows on your face', ], - backgroundColor: TColors.primary.withOpacity(0.1), - textColor: TColors.primary, - iconColor: TColors.primary, - borderColor: TColors.primary.withOpacity(0.3), + ); } } diff --git a/sigap-mobile/lib/src/features/daily-ops/data/repositories/units_repository.dart b/sigap-mobile/lib/src/features/daily-ops/data/repositories/units_repository.dart index 4cbae1d..ca1a817 100644 --- a/sigap-mobile/lib/src/features/daily-ops/data/repositories/units_repository.dart +++ b/sigap-mobile/lib/src/features/daily-ops/data/repositories/units_repository.dart @@ -1,4 +1,5 @@ import 'package:get/get.dart'; +import 'package:logger/web.dart'; import 'package:sigap/src/cores/services/supabase_service.dart'; import 'package:sigap/src/features/daily-ops/data/models/models/units_model.dart'; import 'package:sigap/src/utils/exceptions/exceptions.dart'; @@ -12,11 +13,14 @@ class UnitRepository extends GetxController { // Get all units Future> getAllUnits() async { try { - final units = await _supabase - .from('units') - .select() - .order('name'); - + final units = await _supabase.from('units').select().order('name'); + + if (units.isEmpty) { + return []; + } + + Logger().i('Fetched ${units.length} units from database'); + return units.map((unit) => UnitModel.fromJson(unit)).toList(); } on PostgrestException catch (error) { throw TExceptions.fromCode(error.code ?? 'unknown_error'); @@ -28,12 +32,13 @@ class UnitRepository extends GetxController { // Get unit by ID Future getUnitById(String codeUnit) async { try { - final unit = await _supabase - .from('units') - .select('*, officers(*), patrol_units(*), unit_statistics(*)') - .eq('code_unit', codeUnit) - .single(); - + final unit = + await _supabase + .from('units') + .select('*, officers(*), patrol_units(*), unit_statistics(*)') + .eq('code_unit', codeUnit) + .single(); + return UnitModel.fromJson(unit); } on PostgrestException catch (error) { throw TExceptions.fromCode(error.code ?? 'unknown_error'); @@ -50,7 +55,7 @@ class UnitRepository extends GetxController { .select() .eq('type', type.name) .order('name'); - + return units.map((unit) => UnitModel.fromJson(unit)).toList(); } on PostgrestException catch (error) { throw TExceptions.fromCode(error.code ?? 'unknown_error'); @@ -67,7 +72,7 @@ class UnitRepository extends GetxController { .select() .eq('city_id', cityId) .order('name'); - + return units.map((unit) => UnitModel.fromJson(unit)).toList(); } on PostgrestException catch (error) { throw TExceptions.fromCode(error.code ?? 'unknown_error'); @@ -77,16 +82,18 @@ class UnitRepository extends GetxController { } // Get units near a location - Future> getUnitsNearLocation(double latitude, double longitude, double radiusInKm) async { + Future> getUnitsNearLocation( + double latitude, + double longitude, + double radiusInKm, + ) async { try { // Use PostGIS to find units within a radius - final units = await _supabase - .rpc('get_units_near_location', params: { - 'lat': latitude, - 'lng': longitude, - 'radius_km': radiusInKm, - }); - + final units = await _supabase.rpc( + 'get_units_near_location', + params: {'lat': latitude, 'lng': longitude, 'radius_km': radiusInKm}, + ); + return units.map((unit) => UnitModel.fromJson(unit)).toList(); } on PostgrestException catch (error) { throw TExceptions.fromCode(error.code ?? 'unknown_error'); diff --git a/sigap-mobile/lib/src/shared/widgets/image_upload/image_source_dialog.dart b/sigap-mobile/lib/src/shared/widgets/image_upload/image_source_dialog.dart index f9b3d3c..45298dd 100644 --- a/sigap-mobile/lib/src/shared/widgets/image_upload/image_source_dialog.dart +++ b/sigap-mobile/lib/src/shared/widgets/image_upload/image_source_dialog.dart @@ -3,6 +3,7 @@ import 'package:get/get.dart'; import 'package:image_picker/image_picker.dart'; import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/sizes.dart'; +import 'package:sigap/src/utils/helpers/helper_functions.dart'; class ImageSourceDialog { static Future show({ @@ -16,62 +17,69 @@ class ImageSourceDialog { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), ), - child: Padding( - padding: const EdgeInsets.all(TSizes.defaultSpace), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - title, - style: TextStyle( - fontSize: TSizes.fontSizeLg, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: TSizes.md), - Text( - message, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: TSizes.fontSizeSm, - color: TColors.textSecondary, - ), - ), - const SizedBox(height: TSizes.spaceBtwItems), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _buildImageSourceOption( - icon: Icons.camera_alt, - label: 'Camera', - onTap: () { - onSourceSelected(ImageSource.camera); - Get.back(); - }, - ), - if (galleryOption) - _buildImageSourceOption( - icon: Icons.image, - label: 'Gallery', - onTap: () { - onSourceSelected(ImageSource.gallery); - Get.back(); - }, + child: Builder( + builder: + (context) => Padding( + padding: const EdgeInsets.all(TSizes.defaultSpace), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: TextStyle( + fontSize: TSizes.fontSizeLg, + fontWeight: FontWeight.bold, + ), ), - ], + const SizedBox(height: TSizes.md), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: TSizes.fontSizeSm, + color: TColors.textSecondary, + ), + ), + const SizedBox(height: TSizes.spaceBtwItems), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildImageSourceOption( + context: context, + icon: Icons.camera_alt, + label: 'Camera', + onTap: () { + onSourceSelected(ImageSource.camera); + Get.back(); + }, + ), + if (galleryOption) + _buildImageSourceOption( + context: context, + icon: Icons.image, + label: 'Gallery', + onTap: () { + onSourceSelected(ImageSource.gallery); + Get.back(); + }, + ), + ], + ), + ], + ), ), - ], - ), ), ), ); } static Widget _buildImageSourceOption({ + required BuildContext context, required IconData icon, required String label, required VoidCallback onTap, }) { + final isDark = THelperFunctions.isDarkMode(context); return GestureDetector( onTap: onTap, child: Column( @@ -79,13 +87,25 @@ class ImageSourceDialog { Container( padding: const EdgeInsets.all(TSizes.md), decoration: BoxDecoration( - color: TColors.primary.withOpacity(0.1), + color: + isDark + ? TColors.cardDark.withOpacity(0.7) + : TColors.primary.withOpacity(0.1), shape: BoxShape.circle, ), - child: Icon(icon, color: TColors.primary, size: TSizes.iconLg), + child: Icon( + icon, + color: isDark ? TColors.cardForegroundDark : TColors.primary, + size: TSizes.iconLg, + ), ), const SizedBox(height: TSizes.sm), - Text(label), + Text( + label, + style: TextStyle( + color: isDark ? TColors.cardForegroundDark : TColors.primary, + ), + ), ], ), ); 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 6a387a0..15f982e 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 @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/sizes.dart'; +import 'package:sigap/src/utils/loaders/circular_loader.dart'; class ImageUploader extends StatelessWidget { final XFile? image; @@ -121,7 +122,7 @@ class ImageUploader extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const CircularProgressIndicator(), + const TCircularLoader(), const SizedBox(height: TSizes.sm), Text( 'Uploading...', diff --git a/sigap-mobile/lib/src/shared/widgets/indicators/step_indicator/styles/standard_step_indicator.dart b/sigap-mobile/lib/src/shared/widgets/indicators/step_indicator/styles/standard_step_indicator.dart index dafd5c3..5e60b94 100644 --- a/sigap-mobile/lib/src/shared/widgets/indicators/step_indicator/styles/standard_step_indicator.dart +++ b/sigap-mobile/lib/src/shared/widgets/indicators/step_indicator/styles/standard_step_indicator.dart @@ -180,7 +180,7 @@ class StandardStepIndicator extends StatelessWidget { isActive ? Icon( index < currentStep ? Icons.check : Icons.circle, - color: Colors.white, + color: isDark ? TColors.primary : TColors.light, size: index < currentStep ? TSizes.iconSm : TSizes.iconXs, ) : Text( diff --git a/sigap-mobile/lib/src/shared/widgets/info/tips_container.dart b/sigap-mobile/lib/src/shared/widgets/info/tips_container.dart index 4bf7770..85e2af0 100644 --- a/sigap-mobile/lib/src/shared/widgets/info/tips_container.dart +++ b/sigap-mobile/lib/src/shared/widgets/info/tips_container.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/sizes.dart'; class TipsContainer extends StatelessWidget { @@ -14,10 +15,10 @@ class TipsContainer extends StatelessWidget { super.key, required this.title, required this.tips, - this.backgroundColor = Colors.blue, - this.borderColor = Colors.blue, - this.textColor = Colors.blue, - this.iconColor = Colors.blue, + this.backgroundColor = TColors.cardInformation, + this.borderColor = TColors.cardInformation, + this.textColor = TColors.cardInformation, + this.iconColor = TColors.cardInformation, this.leadingIcon = Icons.tips_and_updates, }); diff --git a/sigap-mobile/lib/src/utils/constants/colors.dart b/sigap-mobile/lib/src/utils/constants/colors.dart index 4016b11..3e3d001 100644 --- a/sigap-mobile/lib/src/utils/constants/colors.dart +++ b/sigap-mobile/lib/src/utils/constants/colors.dart @@ -43,6 +43,40 @@ class TColors { static const Color lightGrey = Color(0xFFF9F9F9); static const Color white = Color(0xFFFFFFFF); + // Card colors + static const Color cardPrimary = Color(0xFF232425); // Dark card bg + static const Color cardSecondary = Color(0xFFFFFFFF); // Light card bg + static const Color cardForeground = Color(0xFF2F2F2F); // Card text color + static const Color cardMuted = Color(0xFF343536); // Muted card text + static const Color cardMutedForeground = Color( + 0xFFB0B0B0, + ); // Muted text color + static const Color cardAccent = Color(0xFF343536); // Card accent color + static const Color cardDestructive = Color( + 0xFFEF4444, + ); // Card destructive action + static const Color cardSuccess = Color(0xFF38B2AC); // Card success action + static const Color cardWarning = Color(0xFFF59E0B); // Card warning action + static const Color cardInformation = Color( + 0xFF2563EB, + ); // Card information action + + // Card colors (theme-aware) + static const Color cardLight = Color(0xFFFFFFFF); // Light mode card bg + static const Color cardDark = Color(0xFF232425); // Dark mode card bg + static const Color cardBorderLight = Color( + 0xFFE5E5E5, + ); // Light mode card border + static const Color cardBorderDark = Color( + 0xFF343536, + ); // Dark mode card border + static const Color cardForegroundLight = Color( + 0xFF2F2F2F, + ); // Light mode card text + static const Color cardForegroundDark = Color( + 0xFFFFFFFF, + ); // Dark mode card text + // Additional colors static const Color transparent = Colors.transparent; diff --git a/sigap-mobile/lib/src/utils/constants/num_int.dart b/sigap-mobile/lib/src/utils/constants/num_int.dart index 0259eb9..4bf037d 100644 --- a/sigap-mobile/lib/src/utils/constants/num_int.dart +++ b/sigap-mobile/lib/src/utils/constants/num_int.dart @@ -2,5 +2,5 @@ class TNum { // Auth Number static const int oneTimePassword = 6; static const int totalStepViewer = 4; - static const int totalStepOfficer = 5; + static const int totalStepOfficer = 4; } diff --git a/sigap-website/prisma/migrations/20250511105354_add_crime_cleared_to_crimes_table/migration.sql b/sigap-website/prisma/migrations/20250511105354_add_crime_cleared_to_crimes_table/migration.sql index 04659eb..f5871dd 100644 --- a/sigap-website/prisma/migrations/20250511105354_add_crime_cleared_to_crimes_table/migration.sql +++ b/sigap-website/prisma/migrations/20250511105354_add_crime_cleared_to_crimes_table/migration.sql @@ -1,4 +1,4 @@ -/* + /* Warnings: - You are about to drop the `test` table. If the table is not empty, all the data it contains will be lost.