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 92bef55..27bfe1d 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 @@ -85,11 +85,6 @@ class AuthenticationRepository extends GetxController { final isProfileComplete = session?.user.userMetadata?['profile_status'] == 'completed'; - // Log the current state for debugging - Logger().d( - 'Screen redirect state - Session: ${session != null}, Email verified: $isEmailVerified, Profile complete: $isProfileComplete, First time: $isFirstTime, Current route: ${Get.currentRoute}', - ); - // Cek lokasi terlebih dahulu if (await _locationService.isLocationValidForFeature() == false) { _navigateToRoute(AppRoutes.locationWarning); @@ -674,7 +669,7 @@ class AuthenticationRepository extends GetxController { // Don't attempt profile completion while already redirecting throw 'Cannot complete profile during redirection. Please try again.'; } - + try { // Convert to UserModel final userMetadataModel = UserMetadataModel.fromInitUserMetadata( @@ -697,7 +692,7 @@ class AuthenticationRepository extends GetxController { .from('profiles') .insert(completeData.viewerData!.toJson()); } - + // Set redirection flag to ensure we don't navigate before setup is complete _isRedirecting = true; } catch (e) { 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 8da5046..3102736 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,94 +1,67 @@ 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/auth/data/models/kta_model.dart'; +import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart'; +import 'package:sigap/src/features/daily-ops/data/models/index.dart'; import 'package:sigap/src/features/daily-ops/data/repositories/units_repository.dart'; +import 'package:sigap/src/features/personalization/data/models/models/user_metadata_model.dart'; +import 'package:sigap/src/features/personalization/data/repositories/officers_repository.dart'; +import 'package:sigap/src/features/personalization/data/repositories/users_repository.dart'; +import 'package:sigap/src/shared/widgets/state_screeen/state_screen.dart'; +import 'package:sigap/src/utils/constants/image_strings.dart'; +import 'package:sigap/src/utils/helpers/network_manager.dart'; +import 'package:sigap/src/utils/popups/circular_full_screen_loader.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); // 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 + // Controllers for officer info final nrpController = TextEditingController(); - final rankController = TextEditingController(); - final unitIdController = TextEditingController(); - final patrolUnitIdController = TextEditingController(); final nameController = TextEditingController(); + final rankController = TextEditingController(); final positionController = TextEditingController(); final phoneController = TextEditingController(); - final emailController = TextEditingController(); + final unitIdController = TextEditingController(); final validUntilController = TextEditingController(); - final avatarController = TextEditingController(); - final qrCodeController = TextEditingController(); - 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 + // New fields based on the model + final placeOfBirthController = TextEditingController(); + final dateOfBirthController = TextEditingController(); // Error states final RxString nrpError = ''.obs; + final RxString nameError = ''.obs; final RxString rankError = ''.obs; final RxString unitIdError = ''.obs; - final RxString patrolUnitIdError = ''.obs; - final RxString nameError = ''.obs; final RxString positionError = ''.obs; final RxString phoneError = ''.obs; - final RxString emailError = ''.obs; final RxString validUntilError = ''.obs; - final RxString avatarError = ''.obs; - final RxString qrCodeError = ''.obs; - 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; + final RxString placeOfBirthError = ''.obs; + final RxString dateOfBirthError = ''.obs; // Logger instance final Logger logger = Logger(); - // Make sure repositories are properly initialized + // Unit repository late final UnitRepository unitRepository; - late final PatrolUnitRepository patrolUnitRepository; // Dropdown open state RxBool isUnitDropdownOpen = false.obs; + // Date selection related + Rx selectedValidUntil = Rx(null); + Rx selectedDateOfBirth = Rx(null); + @override void onInit() { super.onInit(); @@ -102,9 +75,7 @@ class OfficerInfoController extends GetxController { void initRepositories() { // Check if repositories are already registered with GetX unitRepository = Get.find(); - patrolUnitRepository = Get.find(); - - Logger().i('UnitRepository and PatrolUnitRepository initialized'); + logger.i('UnitRepository initialized'); } // Fetch available units with improved error handling @@ -139,377 +110,206 @@ class OfficerInfoController extends GetxController { } } - // 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 valid until date + void setValidUntilDate(DateTime date) { + selectedValidUntil.value = date; + validUntilController.text = formatDate(date); + validUntilError.value = ''; } - // Set patrol unit type (car or motorcycle) - void setPatrolUnitType(PatrolUnitType type) { - selectedPatrolType.value = type; - patrolTypeController.text = type.name; + // Set date of birth + void setDateOfBirth(DateTime date) { + selectedDateOfBirth.value = date; + dateOfBirthController.text = formatDate(date); + dateOfBirthError.value = ''; } - // Set patrol selection mode (individual, group, or create new) - void setPatrolSelectionMode(PatrolSelectionMode mode) { - patrolSelectionMode.value = mode; + // Format date as yyyy-MM-dd + String formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } - // Reset fields when changing modes - if (mode == PatrolSelectionMode.createNew) { - isCreatingNewPatrolUnit.value = true; - patrolUnitIdController.clear(); - selectedPatrolUnitName.value = ''; - } else { - isCreatingNewPatrolUnit.value = false; + // Populate fields from KTA data + void populateFromKta(KtaModel ktaData) { + if (ktaData.nrp.isNotEmpty && nrpController.text.isEmpty) { + nrpController.text = ktaData.nrp; + } + + if (ktaData.name.isNotEmpty && nameController.text.isEmpty) { + nameController.text = ktaData.name; + } + + if (ktaData.rank?.isNotEmpty == true && rankController.text.isEmpty) { + rankController.text = ktaData.rank!; + } + + if (ktaData.position?.isNotEmpty == true && + positionController.text.isEmpty) { + positionController.text = ktaData.position!; } } - // Validate new patrol unit data - bool validateNewPatrolUnit() { - bool isValid = true; + // Validate the form + bool validate(GlobalKey? formKey) { + clearErrors(); - // Clear previous errors - patrolNameError.value = ''; - patrolTypeError.value = ''; - patrolRadiusError.value = ''; + bool isValid = formKey?.currentState?.validate() ?? false; - // Validate patrol unit name - final nameValidation = TValidators.validateUserInput( - 'Patrol Unit Name', - patrolNameController.text, - 50, - ); - if (nameValidation != null) { - patrolNameError.value = nameValidation; + // Additional validation for required fields + if (nrpController.text.isEmpty) { + nrpError.value = 'NRP is required'; isValid = false; } - // Validate patrol unit type - if (patrolTypeController.text.isEmpty) { - patrolTypeError.value = 'Patrol type is required'; + if (nameController.text.isEmpty) { + nameError.value = 'Name 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; + unitIdError.value = 'Please select a unit'; + isValid = false; } + isFormValid.value = isValid; + return isValid; + } + + void updateOfficer(OfficerModel officer, UserMetadataModel metadata) async { 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 + TCircularFullScreenLoader.openLoadingDialog(); - // 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(), - ), + final isConnected = await NetworkManager.instance.isConnected(); + if (!isConnected) { + TLoaders.errorSnackBar( + title: 'No Internet Connection', + message: 'Please check your internet connection and try again.', + ); + TCircularFullScreenLoader.stopLoading(); + return; + } + + // Validate the form before proceeding + if (!validate(null)) { + TLoaders.errorSnackBar( + title: 'Validation Error', + message: 'Please fix the errors in the form before submitting.', + ); + TCircularFullScreenLoader.stopLoading(); + return; + } + + final data = officer.copyWith( + nrp: nrpController.text, + name: nameController.text, + rank: rankController.text, + position: positionController.text, + phone: phoneController.text, + unitId: unitIdController.text, + validUntil: selectedValidUntil.value, + placeOfBirth: placeOfBirthController.text, + dateOfBirth: selectedDateOfBirth.value, ); + + Logger().i('Updating officer with data: ${data.toJson()}'); + - // 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) { + // final updatedOfficer = await OfficerRepository.instance.updateOfficer( + // data, + // ); + + // if (updatedOfficer == null) { + // TLoaders.errorSnackBar( + // title: 'Update Failed', + // message: 'Failed to update officer information. Please try again.', + // ); + // TCircularFullScreenLoader.stopLoading(); + // return; + // } + + // final userMetadata = + // metadata.copyWith(profileStatus: 'completed').toAuthMetadataJson(); + + // await UserRepository.instance.updateUserMetadata(userMetadata); + + // TLoaders.successSnackBar( + // title: 'Update Successful', + // message: 'Officer information updated successfully.', + // ); + + // resetForm(); + // TCircularFullScreenLoader.stopLoading(); + + // Get.off( + // () => StateScreen( + // title: 'Officer Information Created', + // subtitle: 'Officer information has been successfully create.', + // primaryButtonTitle: 'Back to signin', + // image: TImages.womanHuggingEarth, + // showButton: true, + // onPressed: () => AuthenticationRepository.instance.screenRedirect(), + // ), + // ); + } catch (e) { + logger.e('Error updating officer: $e'); + TCircularFullScreenLoader.stopLoading(); TLoaders.errorSnackBar( - title: 'Error', - message: 'Failed to create patrol unit: $error', + title: 'Update Failed', + message: 'An error occurred while updating officer information.', ); - return false; } } - // Join an existing patrol unit - void joinPatrolUnit(PatrolUnitModel patrolUnit) { - patrolUnitIdController.text = patrolUnit.id; - selectedPatrolUnitName.value = patrolUnit.name; + void resetForm() { + nrpController.clear(); + nameController.clear(); + rankController.clear(); + positionController.clear(); + phoneController.clear(); + unitIdController.clear(); + validUntilController.clear(); + placeOfBirthController.clear(); + dateOfBirthController.clear(); - // 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(); - if (formKey.currentState?.validate() ?? false) { - return true; - } - - final nrpValidation = TValidators.validateUserInput( - 'NRP', - nrpController.text, - 50, - ); - if (nrpValidation != null) { - nrpError.value = nrpValidation; - isFormValid.value = false; - } - - final rankValidation = TValidators.validateUserInput( - 'Rank', - rankController.text, - 50, - ); - if (rankValidation != null) { - rankError.value = rankValidation; - isFormValid.value = false; - } - - final unitIdValidation = TValidators.validateUserInput( - 'Unit ID', - unitIdController.text, - 50, - ); - if (unitIdValidation != null) { - unitIdError.value = unitIdValidation; - isFormValid.value = false; - } - - final patrolUnitIdValidation = TValidators.validateUserInput( - 'Patrol Unit ID', - patrolUnitIdController.text, - 50, - ); - if (patrolUnitIdValidation != null) { - patrolUnitIdError.value = patrolUnitIdValidation; - isFormValid.value = false; - } - - final nameValidation = TValidators.validateUserInput( - 'Name', - nameController.text, - 50, - ); - if (nameValidation != null) { - nameError.value = nameValidation; - isFormValid.value = false; - } - - final positionValidation = TValidators.validateUserInput( - 'Position', - positionController.text, - 50, - ); - if (positionValidation != null) { - positionError.value = positionValidation; - isFormValid.value = false; - } - - final phoneValidation = TValidators.validateUserInput( - 'Phone', - phoneController.text, - 50, - ); - if (phoneValidation != null) { - phoneError.value = phoneValidation; - isFormValid.value = false; - } - - final emailValidation = TValidators.validateUserInput( - 'Email', - emailController.text, - 50, - ); - if (emailValidation != null) { - emailError.value = emailValidation; - isFormValid.value = false; - } - - final validUntilValidation = TValidators.validateUserInput( - 'Valid Until', - validUntilController.text, - 50, - ); - if (validUntilValidation != null) { - validUntilError.value = validUntilValidation; - isFormValid.value = false; - } - - final avatarValidation = TValidators.validateUserInput( - 'Avatar', - avatarController.text, - 50, - ); - if (avatarValidation != null) { - avatarError.value = avatarValidation; - isFormValid.value = false; - } - - final qrCodeValidation = TValidators.validateUserInput( - 'QR Code', - qrCodeController.text, - 50, - ); - if (qrCodeValidation != null) { - qrCodeError.value = qrCodeValidation; - isFormValid.value = false; - } - - final bannedReasonValidation = TValidators.validateUserInput( - 'Banned Reason', - bannedReasonController.text, - 50, - ); - if (bannedReasonValidation != null) { - bannedReasonError.value = bannedReasonValidation; - isFormValid.value = false; - } - - final bannedUntilValidation = TValidators.validateUserInput( - 'Banned Until', - bannedUntilController.text, - 50, - ); - if (bannedUntilValidation != null) { - bannedUntilError.value = bannedUntilValidation; - isFormValid.value = false; - } - - // Include validation for new patrol unit if creating one - if (isCreatingNewPatrolUnit.value && !validateNewPatrolUnit()) { - isFormValid.value = false; - } - - return isFormValid.value; + selectedUnitName.value = ''; + selectedValidUntil.value = null; + selectedDateOfBirth.value = null; } + // Clear all error messages void clearErrors() { nrpError.value = ''; + nameError.value = ''; rankError.value = ''; unitIdError.value = ''; - patrolUnitIdError.value = ''; - nameError.value = ''; positionError.value = ''; phoneError.value = ''; - emailError.value = ''; validUntilError.value = ''; - avatarError.value = ''; - qrCodeError.value = ''; - bannedReasonError.value = ''; - bannedUntilError.value = ''; - - // Clear errors for new patrol unit fields - patrolNameError.value = ''; - patrolTypeError.value = ''; - patrolRadiusError.value = ''; + placeOfBirthError.value = ''; + dateOfBirthError.value = ''; } + // Clean up resources @override void onClose() { nrpController.dispose(); - rankController.dispose(); - unitIdController.dispose(); - patrolUnitIdController.dispose(); nameController.dispose(); + rankController.dispose(); positionController.dispose(); phoneController.dispose(); - emailController.dispose(); + unitIdController.dispose(); validUntilController.dispose(); - avatarController.dispose(); - qrCodeController.dispose(); - bannedReasonController.dispose(); - bannedUntilController.dispose(); - - // Dispose controllers for new patrol unit - patrolNameController.dispose(); - patrolTypeController.dispose(); - patrolRadiusController.dispose(); + placeOfBirthController.dispose(); + dateOfBirthController.dispose(); super.onClose(); } } 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 28283d0..3c95650 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 @@ -1,8 +1,9 @@ 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/presentasion/controllers/signup/main/registration_form_controller.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/id-card-verification/id_card_verification_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'; @@ -19,6 +20,12 @@ class OfficerInfoStep extends StatelessWidget { final mainController = Get.find(); mainController.formKey = formKey; + // Check if KTA data exists and populate fields if available + _populateFieldsFromKta( + controller, + mainController.idCardVerificationController, + ); + return Form( key: formKey, child: Column( @@ -34,6 +41,7 @@ class OfficerInfoStep extends StatelessWidget { // NRP field CustomTextField( label: 'NRP', + enabled: controller.nrpController.text.isEmpty, controller: controller.nrpController, validator: TValidators.validateNRP, errorText: controller.nrpError.value, @@ -45,24 +53,34 @@ class OfficerInfoStep extends StatelessWidget { controller.nrpError.value = ''; }, ), - Obx( - () => - 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(), + _buildErrorText(controller.nrpError), + + // Name field + CustomTextField( + label: 'Full Name', + controller: controller.nameController, + validator: + (v) => TValidators.validateUserInput( + 'Name', + v, + 100, + required: true, + ), + errorText: controller.nameError.value, + textInputAction: TextInputAction.next, + hintText: 'Your full name', + onChanged: (value) { + controller.nameController.text = value; + controller.nameError.value = ''; + }, ), + _buildErrorText(controller.nameError), // Rank field CustomTextField( label: 'Rank', controller: controller.rankController, - validator: TValidators.validateRank, + validator: (v) => TValidators.validateUserInput('Rank', v, 100), errorText: controller.rankError.value, textInputAction: TextInputAction.next, hintText: 'e.g., Captain', @@ -71,30 +89,13 @@ class OfficerInfoStep extends StatelessWidget { controller.rankError.value = ''; }, ), - Obx( - () => - 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(), - ), + _buildErrorText(controller.rankError), // Position field CustomTextField( label: 'Position', controller: controller.positionController, - validator: - (v) => TValidators.validateUserInput( - 'Position', - v, - 100, - required: true, - ), + validator: (v) => TValidators.validateUserInput('Position', v, 100), errorText: controller.positionError.value, textInputAction: TextInputAction.next, hintText: 'e.g., Head of Unit', @@ -103,18 +104,73 @@ class OfficerInfoStep extends StatelessWidget { controller.positionError.value = ''; }, ), - Obx( - () => - 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(), + _buildErrorText(controller.positionError), + + // Phone field + CustomTextField( + label: 'Phone Number', + controller: controller.phoneController, + validator: TValidators.validatePhoneNumber, + errorText: controller.phoneError.value, + textInputAction: TextInputAction.next, + keyboardType: TextInputType.phone, + hintText: 'e.g., 08123456789', + onChanged: (value) { + controller.phoneController.text = value; + controller.phoneError.value = ''; + }, ), + _buildErrorText(controller.phoneError), + + // Place of Birth field + CustomTextField( + label: 'Place of Birth', + controller: controller.placeOfBirthController, + validator: + (v) => TValidators.validateUserInput('Place of Birth', v, 100), + errorText: controller.placeOfBirthError.value, + textInputAction: TextInputAction.next, + hintText: 'e.g., Jakarta', + onChanged: (value) { + controller.placeOfBirthController.text = value; + controller.placeOfBirthError.value = ''; + }, + ), + _buildErrorText(controller.placeOfBirthError), + + // Date of Birth field + _buildDateField( + context: context, + controller: controller, + label: 'Date of Birth', + textController: controller.dateOfBirthController, + errorValue: controller.dateOfBirthError, + hintText: 'YYYY-MM-DD', + initialDate: DateTime.now().subtract( + const Duration(days: 365 * 18), + ), // Default to 18 years ago + firstDate: DateTime(1950), + lastDate: DateTime.now(), + onDateSelected: controller.setDateOfBirth, + ), + _buildErrorText(controller.dateOfBirthError), + + // Valid Until field + _buildDateField( + context: context, + controller: controller, + label: 'Valid Until', + textController: controller.validUntilController, + errorValue: controller.validUntilError, + hintText: 'YYYY-MM-DD', + initialDate: DateTime.now().add( + const Duration(days: 365), + ), // Default to 1 year from now + firstDate: DateTime.now(), + lastDate: DateTime(2100), + onDateSelected: controller.setValidUntilDate, + ), + _buildErrorText(controller.validUntilError), const SizedBox(height: TSizes.spaceBtwSections), @@ -127,154 +183,142 @@ class OfficerInfoStep extends StatelessWidget { const SizedBox(height: TSizes.spaceBtwItems), // Unit dropdown - _buildUnitDropdown(controller), + _buildUnitDropdown( + controller, + mainController.idCardVerificationController, + ), 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', - ), - ), + // Note about patrol unit assignment + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: TColors.info.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: TColors.info), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, color: TColors.info), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Patrol Unit Assignment', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + color: TColors.info, ), - - 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(), - ), - ], - ), - ); - }, + ), + const SizedBox(height: 4), + Text( + 'After registration is complete, you will be able to join or create a patrol unit from the app.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ], + ), ), ], ), ); } - // 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; - } + /// Populates form fields from KTA data if available + void _populateFieldsFromKta( + OfficerInfoController controller, + IdCardVerificationController mainController, + ) { + // Check if KTA data exists in the main controller + final KtaModel? ktaData = mainController.ktaModel.value; - Get.to(() => PatrolUnitSelectionScreen()); + if (ktaData != null) { + controller.populateFromKta(ktaData); + } + } + + // Helper to build error text consistently + Widget _buildErrorText(RxString errorValue) { + return Obx( + () => + errorValue.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + errorValue.value, + style: TextStyle(color: Colors.red[700], fontSize: 12), + ), + ) + : const SizedBox.shrink(), + ); + } + + // Build date picker field + Widget _buildDateField({ + required BuildContext context, + required OfficerInfoController controller, + required String label, + required TextEditingController textController, + required RxString errorValue, + required String hintText, + required DateTime initialDate, + required DateTime firstDate, + required DateTime lastDate, + required Function(DateTime) onDateSelected, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CustomTextField( + label: label, + controller: textController, + readOnly: true, // Make read-only since we use date picker + errorText: errorValue.value, + hintText: hintText, + suffixIcon: IconButton( + icon: const Icon(Icons.calendar_today), + onPressed: () async { + final date = await showDatePicker( + context: context, + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + builder: (context, child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: ColorScheme.light( + primary: Theme.of(context).primaryColor, + onPrimary: Colors.white, + surface: Colors.white, + onSurface: Colors.black, + ), + ), + child: child!, + ); + }, + ); + + if (date != null) { + onDateSelected(date); + } + }, + ), + ), + ], + ); } // Build unit dropdown selection - Widget _buildUnitDropdown(OfficerInfoController controller) { + Widget _buildUnitDropdown( + OfficerInfoController controller, + IdCardVerificationController idCardController, + ) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -351,6 +395,34 @@ class OfficerInfoStep extends StatelessWidget { (unit) => unit.codeUnit == controller.unitIdController.text, ); + // If units are loaded and we have KTA data with a police unit, + // try to find a matching unit and select it + if (controller.availableUnits.isNotEmpty && + controller.unitIdController.text.isEmpty) { + final ktaUnit = + idCardController.ktaModel.value?.policeUnit ?? ''; + + // More flexible matching logic to find the best matching unit + final matchingUnit = controller.availableUnits.firstWhereOrNull( + (unit) => + // Try exact match first + unit.name.toLowerCase() == ktaUnit.toLowerCase() || + // Then try contains match + unit.name.toLowerCase().contains( + ktaUnit.toLowerCase(), + ) || + // Or if the KTA unit contains the available unit name + ktaUnit.toLowerCase().contains(unit.name.toLowerCase()), + ); + + if (matchingUnit != null) { + // Use Future.microtask to avoid setState during build + Future.microtask( + () => controller.onUnitSelected(matchingUnit), + ); + } + } + return Column( children: [ // Dropdown Selection Button @@ -580,18 +652,7 @@ class OfficerInfoStep extends StatelessWidget { ), // 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), + _buildErrorText(controller.unitIdError), ], ); }, 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 index 4fbbd18..9c57f96 100644 --- 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 @@ -1,16 +1,19 @@ 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/features/daily-ops/controllers/patrol_unit_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/helpers/helper_functions.dart'; -import 'package:sigap/src/utils/validators/validation.dart'; +import 'package:sigap/src/utils/popups/loaders.dart'; class PatrolUnitSelectionScreen extends StatelessWidget { PatrolUnitSelectionScreen({super.key}); - final controller = Get.find(); + // Use the dedicated patrol unit controller + final officerController = Get.find(); + final controller = Get.find(); @override Widget build(BuildContext context) { @@ -19,9 +22,13 @@ class PatrolUnitSelectionScreen extends StatelessWidget { appBar: AppBar( title: const Text('Configure Patrol Unit'), leading: IconButton( - icon: const Icon(Icons.arrow_back), + icon: Icon( + Icons.arrow_back, + color: isDark ? TColors.accent : TColors.primary, + ), onPressed: () => Get.back(), ), + centerTitle: true, ), body: Padding( padding: const EdgeInsets.all(TSizes.defaultSpace), @@ -35,7 +42,7 @@ class PatrolUnitSelectionScreen extends StatelessWidget { color: isDark ? TColors.accent.withOpacity(0.1) - : TColors.lightContainer.withOpacity(0.1), + : TColors.primary.withOpacity(0.1), borderRadius: BorderRadius.circular(8), ), child: Row( @@ -81,111 +88,7 @@ class PatrolUnitSelectionScreen extends StatelessWidget { style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), ), const SizedBox(height: TSizes.spaceBtwInputFields / 2), - GetX( - builder: (controller) { - final isCarSelected = - controller.selectedPatrolType.value == PatrolUnitType.car; - final isMotorcycleSelected = - controller.selectedPatrolType.value == - PatrolUnitType.motorcycle; - - final isDark = THelperFunctions.isDarkMode(context); - - return Row( - children: [ - Expanded( - child: InkWell( - onTap: () { - controller.setPatrolUnitType(PatrolUnitType.car); - }, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: - isCarSelected - ? isDark - ? TColors.accent.withOpacity(0.1) - : TColors.primary.withOpacity(0.1) - : Colors.transparent, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: - isCarSelected - ? isDark - ? TColors.accent - : TColors.primary - : TColors.accent.withOpacity(0.3), - ), - ), - child: Column( - children: [ - Icon( - Icons.directions_car, - color: - isCarSelected - ? isDark - ? TColors.accent - : TColors.primary - : TColors.accent.withOpacity(0.3), - 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: - isMotorcycleSelected - ? isDark - ? TColors.accent.withOpacity(0.1) - : TColors.primary.withOpacity(0.1) - : Colors.transparent, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: - isMotorcycleSelected - ? isDark - ? TColors.accent - : TColors.primary - : TColors.accent.withOpacity(0.3), - ), - ), - child: Column( - children: [ - Icon( - Icons.motorcycle, - color: - isMotorcycleSelected - ? isDark - ? TColors.accent - : TColors.primary - : TColors.accent.withOpacity(0.3), - size: 32, - ), - const SizedBox(height: TSizes.sm), - const Text('Motorcycle'), - ], - ), - ), - ), - ), - ], - ); - }, - ), + _buildPatrolTypeBasicSelection(), const SizedBox(height: TSizes.spaceBtwSections), @@ -195,52 +98,21 @@ class PatrolUnitSelectionScreen extends StatelessWidget { 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, - context, - ), - - const SizedBox(width: TSizes.spaceBtwInputFields), - - _buildModeTab( - controller, - PatrolSelectionMode.group, - 'Group', - Icons.group, - context, - ), - const SizedBox(width: TSizes.spaceBtwInputFields), - - _buildModeTab( - controller, - PatrolSelectionMode.createNew, - 'Create New', - Icons.add_circle, - context, - ), - ], - ), - ), + _buildSelectionModeTabs(), const SizedBox(height: TSizes.spaceBtwInputFields), // Patrol Unit Selection/Creation based on mode Expanded( - child: GetX( + child: GetBuilder( + id: 'patrol_selection', builder: (controller) { switch (controller.patrolSelectionMode.value) { case PatrolSelectionMode.individual: case PatrolSelectionMode.group: - return _buildExistingPatrolUnitSelection(controller); + return _buildExistingPatrolUnitSelection(); case PatrolSelectionMode.createNew: - return _buildCreatePatrolUnitForm(controller); + return _buildCreatePatrolUnitForm(); } }, ), @@ -254,30 +126,7 @@ class PatrolUnitSelectionScreen extends StatelessWidget { 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, - ); - } - }, + onPressed: _handleConfirmButton, style: ElevatedButton.styleFrom( backgroundColor: TColors.primary, foregroundColor: Colors.white, @@ -293,62 +142,196 @@ class PatrolUnitSelectionScreen extends StatelessWidget { ); } - // Helper method to build a selection mode tab - Widget _buildModeTab( - OfficerInfoController controller, - PatrolSelectionMode mode, - String label, - IconData icon, - BuildContext context, - ) { - final bool isSelected = controller.patrolSelectionMode.value == mode; - final isDark = THelperFunctions.isDarkMode(context); + // Handle the confirm button press based on mode + void _handleConfirmButton() { + 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 { + TLoaders.errorSnackBar( + title: 'Patrol Unit Required', + message: 'Please select a patrol unit', + ); + } + } - // define the color based on selection and theme - final boxDecorationColor = - isSelected - ? (isDark - ? TColors.accent.withOpacity(0.1) - : TColors.primary.withOpacity(0.1)) - : TColors.transparent; + // Build patrol type selection (basic car/motorcycle selection) + Widget _buildPatrolTypeBasicSelection() { + return GetBuilder( + id: 'patrol_type_selector', + builder: (controller) { + final isDark = THelperFunctions.isDarkMode(Get.context!); + final isCarSelected = + controller.selectedPatrolType.value == PatrolUnitTypeExtended.car; + final isMotorcycleSelected = + controller.selectedPatrolType.value == + PatrolUnitTypeExtended.motorcycle; - final color = - isSelected - ? (isDark ? TColors.accent : TColors.primary) - : TColors.accent.withOpacity(0.3); + return Row( + children: [ + Expanded( + child: InkWell( + onTap: + () => controller.setPatrolUnitType( + PatrolUnitTypeExtended.car, + ), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: + isCarSelected + ? isDark + ? TColors.accent.withOpacity(0.1) + : TColors.primary.withOpacity(0.1) + : isDark + ? TColors.accent.withOpacity(0.05) + : TColors.lightContainer.withOpacity(0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isCarSelected + ? isDark + ? TColors.accent + : TColors.primary + : isDark + ? TColors.accent.withOpacity(0.3) + : TColors.primary.withOpacity(0.3), + ), + ), + child: Column( + children: [ + Icon( + Icons.directions_car, + color: + isCarSelected + ? isDark + ? TColors.accent + : TColors.primary + : isDark + ? TColors.accent.withOpacity(0.3) + : TColors.primary.withOpacity(0.3), + size: 32, + ), + const SizedBox(height: TSizes.sm), + const Text('Car'), + ], + ), + ), + ), + ), + const SizedBox(width: TSizes.spaceBtwInputFields), + Expanded( + child: InkWell( + onTap: + () => controller.setPatrolUnitType( + PatrolUnitTypeExtended.motorcycle, + ), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: + isMotorcycleSelected + ? isDark + ? TColors.accent.withOpacity(0.1) + : TColors.primary.withOpacity(0.1) + : isDark + ? TColors.accent.withOpacity(0.05) + : TColors.lightContainer.withOpacity(0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isMotorcycleSelected + ? isDark + ? TColors.accent + : TColors.primary + : isDark + ? TColors.accent.withOpacity(0.3) + : TColors.primary.withOpacity(0.3), + ), + ), + child: Column( + children: [ + Icon( + Icons.motorcycle, + color: + isMotorcycleSelected + ? isDark + ? TColors.accent + : TColors.primary + : isDark + ? TColors.accent.withOpacity(0.3) + : TColors.primary.withOpacity(0.3), + size: 32, + ), + const SizedBox(height: TSizes.sm), + const Text('Motorcycle'), + ], + ), + ), + ), + ), + ], + ); + }, + ); + } - return Expanded( - child: InkWell( - onTap: () => controller.setPatrolSelectionMode(mode), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: boxDecorationColor, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: color), - ), - child: Column( - children: [ - Icon(icon, color: color), - const SizedBox(height: 4), - Text(label, style: TextStyle(color: color)), - ], - ), - ), - ), + // Build selection mode tabs (individual/group/create new) + Widget _buildSelectionModeTabs() { + return GetBuilder( + id: 'patrol_mode_tabs', + builder: (controller) { + return Row( + children: [ + _buildModeTab( + controller, + PatrolSelectionMode.individual, + 'Individual', + Icons.person, + ), + const SizedBox(width: TSizes.spaceBtwInputFields), + _buildModeTab( + controller, + PatrolSelectionMode.group, + 'Group', + Icons.group, + ), + const SizedBox(width: TSizes.spaceBtwInputFields), + _buildModeTab( + controller, + PatrolSelectionMode.createNew, + 'Create New', + Icons.add_circle, + ), + ], + ); + }, ); } // Build existing patrol unit selection list - Widget _buildExistingPatrolUnitSelection(OfficerInfoController controller) { + Widget _buildExistingPatrolUnitSelection() { + final isDark = THelperFunctions.isDarkMode(Get.context!); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Select Patrol Unit:', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + // Category label based on selection mode + Text( + controller.patrolSelectionMode.value == PatrolSelectionMode.individual + ? 'Select Individual Patrol Unit:' + : 'Select Group Patrol Unit:', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), ), + const SizedBox(height: TSizes.spaceBtwInputFields / 2), + GetX( builder: (controller) { if (controller.isLoadingPatrolUnits.value) { @@ -366,9 +349,42 @@ class PatrolUnitSelectionScreen extends StatelessWidget { final filteredUnits = controller.getFilteredPatrolUnits(); if (filteredUnits.isEmpty) { - return const Expanded( - child: Center( - child: Text('No patrol units available for this type'), + // Show different messages based on the filter mode + String message = ''; + if (controller.patrolSelectionMode.value == + PatrolSelectionMode.individual) { + message = 'No individual patrol units available for this type'; + } else if (controller.patrolSelectionMode.value == + PatrolSelectionMode.group) { + message = 'No group patrol units available for this type'; + } else { + message = 'No patrol units available for this type'; + } + + return Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(message), + const SizedBox(height: 12), + TextButton.icon( + onPressed: + () => controller.setPatrolSelectionMode( + PatrolSelectionMode.createNew, + ), + icon: Icon( + Icons.add, + color: isDark ? TColors.accent : TColors.primary, + ), + label: Text( + 'Create a new patrol unit instead?', + style: TextStyle( + fontWeight: FontWeight.bold, + color: isDark ? TColors.accent : TColors.primary, + ), + ), + ), + ], ), ); } @@ -399,9 +415,18 @@ class PatrolUnitSelectionScreen extends StatelessWidget { : FontWeight.normal, ), ), - subtitle: Text( - 'Members: ${patrolUnit.members?.length ?? 0}', + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Members: ${patrolUnit.memberCount ?? 0}'), + if (patrolUnit.category?.isNotEmpty == true) + Text( + 'Type: ${patrolUnit.category![0].toUpperCase()}${patrolUnit.category!.substring(1)}', + style: const TextStyle(fontSize: 12), + ), + ], ), + isThreeLine: true, leading: Icon( isCarType ? Icons.directions_car : Icons.motorcycle, color: isUnitSelected ? TColors.primary : null, @@ -427,7 +452,9 @@ class PatrolUnitSelectionScreen extends StatelessWidget { } // Build create new patrol unit form - Widget _buildCreatePatrolUnitForm(OfficerInfoController controller) { + Widget _buildCreatePatrolUnitForm() { + final isDark = THelperFunctions.isDarkMode(Get.context!); + return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -446,74 +473,701 @@ class PatrolUnitSelectionScreen extends StatelessWidget { (v) => TValidators.validateUserInput('Patrol Unit Name', v, 30), errorText: controller.patrolNameError.value, textInputAction: TextInputAction.next, - hintText: 'e.g., Alpha Team', + hintText: 'e.g., ALPHA-JBR01-C1', + prefixIcon: const Icon(Icons.label_outline), 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(), + + // Error text display + Obx( + () => + controller.patrolNameError.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(top: 8.0, bottom: 4.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(), + // Helper text for name format + Padding( + padding: const EdgeInsets.only(left: 8.0, bottom: 16.0), + child: Text( + 'Format: {CALLSIGN}-{UNIT_CODE}-{TYPE_CODE}{SEQ}', + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.grey[400] : Colors.grey[600], + fontStyle: FontStyle.italic, + ), + ), ), + + // Patrol Type Selection with expanded options + _buildPatrolTypeSelector(controller), + + const SizedBox(height: TSizes.spaceBtwInputFields), + + // Patrol Status Selection + _buildPatrolStatusSelector(controller), + + const SizedBox(height: TSizes.spaceBtwInputFields), + + // Member Count Field with category selection + _buildMemberCountField(controller), + + // Patrol Radius Field with dynamic recommendations based on type + _buildPatrolRadiusField(controller), + + const SizedBox(height: TSizes.spaceBtwSections), + + // Show summary of the patrol unit being created + _buildPatrolUnitSummary(controller), ], ), ); } + + // Build patrol type selector with expanded options + Widget _buildPatrolTypeSelector(OfficerInfoController controller) { + final isDark = THelperFunctions.isDarkMode(Get.context!); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Patrol Type:', + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + color: isDark ? Colors.white : Colors.black87, + ), + ), + const SizedBox(height: 8), + + // Wrap with GetBuilder instead of Obx for complex widget updates + GetBuilder( + id: 'patrol_type_selector', + builder: (controller) { + return Wrap( + spacing: 8, + children: + PatrolUnitTypeExtended.values.map((type) { + final isSelected = + controller.patrolTypeController.text.toLowerCase() == + type.name.toLowerCase(); + + return ChoiceChip( + label: Text( + type.name[0].toUpperCase() + type.name.substring(1), + ), + selected: isSelected, + onSelected: (selected) { + if (selected) { + controller.patrolTypeController.text = type.name; + controller.patrolTypeError.value = ''; + + // Update the recommended radius based on type + switch (type) { + case PatrolUnitTypeExtended.car: + controller.patrolRadiusController.text = '6500'; + break; + case PatrolUnitTypeExtended.motorcycle: + controller.patrolRadiusController.text = '4000'; + break; + case PatrolUnitTypeExtended.foot: + controller.patrolRadiusController.text = '1000'; + break; + case PatrolUnitTypeExtended.drone: + controller.patrolRadiusController.text = '3000'; + break; + case PatrolUnitTypeExtended.mixed: + controller.patrolRadiusController.text = '4000'; + break; + } + controller.update([ + 'patrol_type_selector', + 'patrol_radius_field', + 'patrol_summary', + ]); + } + }, + backgroundColor: + isDark ? Colors.grey[800] : Colors.grey[200], + selectedColor: + isDark + ? TColors.accent.withOpacity(0.3) + : TColors.primary.withOpacity(0.2), + labelStyle: TextStyle( + color: + isSelected + ? (isDark ? TColors.accent : TColors.primary) + : (isDark ? Colors.white70 : Colors.black87), + fontWeight: + isSelected ? FontWeight.bold : FontWeight.normal, + ), + shape: StadiumBorder( + side: BorderSide( + color: + isSelected + ? (isDark ? TColors.accent : TColors.primary) + : Colors.transparent, + width: 1.5, + ), + ), + avatar: + isSelected + ? Icon( + _getIconForPatrolType(type), + size: 16, + color: + isDark ? TColors.accent : TColors.primary, + ) + : null, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ); + }).toList(), + ); + }, + ), + + // Display error text + Obx( + () => + controller.patrolTypeError.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(top: 8.0, bottom: 4.0), + child: Text( + controller.patrolTypeError.value, + style: TextStyle(color: Colors.red[700], fontSize: 12), + ), + ) + : const SizedBox.shrink(), + ), + ], + ); + } + + // Helper to get icon for patrol type + IconData _getIconForPatrolType(PatrolUnitTypeExtended type) { + switch (type) { + case PatrolUnitTypeExtended.car: + return Icons.directions_car; + case PatrolUnitTypeExtended.motorcycle: + return Icons.motorcycle; + case PatrolUnitTypeExtended.foot: + return Icons.directions_walk; + case PatrolUnitTypeExtended.drone: + return Icons.flight; + case PatrolUnitTypeExtended.mixed: + return Icons.group; + } + } + + // Build patrol status selector + Widget _buildPatrolStatusSelector(OfficerInfoController controller) { + final isDark = THelperFunctions.isDarkMode(Get.context!); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Patrol Status:', + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + color: isDark ? Colors.white : Colors.black87, + ), + ), + const SizedBox(height: 8), + + // Use GetBuilder instead of Obx for complex widget + GetBuilder( + id: 'patrol_status_selector', + builder: (controller) { + return Wrap( + spacing: 8, + children: + PatrolUnitStatus.values.map((status) { + // Convert enum to display text (e.g., onDuty -> On Duty) + final displayName = + status.name == 'onDuty' + ? 'On Duty' + : status.name == 'offDuty' + ? 'Off Duty' + : status.name[0].toUpperCase() + + status.name.substring(1); + + final isSelected = + controller.patrolStatusController.text.toLowerCase() == + status.name.toLowerCase(); + + return ChoiceChip( + label: Text(displayName), + selected: isSelected, + onSelected: (selected) { + if (selected) { + controller.patrolStatusController.text = status.name; + controller.patrolStatusError.value = ''; + controller.update([ + 'patrol_status_selector', + 'patrol_summary', + ]); + } + }, + backgroundColor: + isDark ? Colors.grey[800] : Colors.grey[200], + selectedColor: + isDark + ? TColors.accent.withOpacity(0.3) + : TColors.primary.withOpacity(0.2), + labelStyle: TextStyle( + color: + isSelected + ? (isDark ? TColors.accent : TColors.primary) + : (isDark ? Colors.white70 : Colors.black87), + fontWeight: + isSelected ? FontWeight.bold : FontWeight.normal, + ), + shape: StadiumBorder( + side: BorderSide( + color: + isSelected + ? (isDark ? TColors.accent : TColors.primary) + : Colors.transparent, + width: 1.5, + ), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ); + }).toList(), + ); + }, + ), + + // Display error for patrol status + Obx( + () => + controller.patrolStatusError.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(top: 8.0, bottom: 4.0), + child: Text( + controller.patrolStatusError.value, + style: TextStyle(color: Colors.red[700], fontSize: 12), + ), + ) + : const SizedBox.shrink(), + ), + ], + ); + } + + // Build member count field with automatic category + Widget _buildMemberCountField(OfficerInfoController controller) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CustomTextField( + label: 'Member Count', + controller: controller.memberCountController, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + hintText: 'e.g., 2', + prefixIcon: const Icon(Icons.group_outlined), + errorText: controller.memberCountError.value, + onChanged: (value) { + controller.memberCountController.text = value; + controller.memberCountError.value = ''; + + // Update category based on member count + try { + int count = int.parse(value); + controller.categoryController.text = + count > 1 ? 'group' : 'individual'; + controller.update(['member_count_category', 'patrol_summary']); + } catch (e) { + controller.categoryController.text = ''; + controller.update(['member_count_category', 'patrol_summary']); + } + }, + validator: (v) { + if (v == null || v.isEmpty) return 'Member count is required'; + try { + int count = int.parse(v); + if (count <= 0) return 'Must have at least 1 member'; + return null; + } catch (e) { + return 'Please enter a valid number'; + } + }, + ), + + // Display error message + Obx( + () => + controller.memberCountError.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(top: 8.0, bottom: 4.0), + child: Text( + controller.memberCountError.value, + style: TextStyle(color: Colors.red[700], fontSize: 12), + ), + ) + : const SizedBox.shrink(), + ), + + // Show category based on member count + GetBuilder( + id: 'member_count_category', + builder: (controller) { + final category = controller.categoryController.text; + if (category.isEmpty) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(left: 8.0, top: 4.0, bottom: 8.0), + child: Row( + children: [ + Text( + 'Category: ', + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + Text( + category[0].toUpperCase() + category.substring(1), + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + color: + category == 'group' ? TColors.primary : Colors.teal, + ), + ), + const SizedBox(width: 4), + Icon( + category == 'group' ? Icons.groups : Icons.person, + size: 14, + color: category == 'group' ? TColors.primary : Colors.teal, + ), + ], + ), + ); + }, + ), + ], + ); + } + + // Build patrol radius field with dynamic recommendations + Widget _buildPatrolRadiusField(OfficerInfoController controller) { + final isDark = THelperFunctions.isDarkMode(Get.context!); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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'; + } + + // Validate against recommended ranges + final patrolType = + controller.patrolTypeController.text.toLowerCase(); + String? rangeError = _validateRadiusRange(patrolType, radius); + if (rangeError != null) return rangeError; + + return null; + } catch (e) { + return 'Please enter a valid number'; + } + }, + errorText: controller.patrolRadiusError.value, + textInputAction: TextInputAction.done, + keyboardType: TextInputType.number, + hintText: '500-8000', + prefixIcon: const Icon(Icons.radio_button_checked), + onChanged: (value) { + controller.patrolRadiusController.text = value; + controller.patrolRadiusError.value = ''; + controller.update(['patrol_summary']); + }, + ), + + // Display error for radius + Obx( + () => + controller.patrolRadiusError.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(top: 8.0, bottom: 4.0), + child: Text( + controller.patrolRadiusError.value, + style: TextStyle(color: Colors.red[700], fontSize: 12), + ), + ) + : const SizedBox.shrink(), + ), + + // Show recommended range based on type + GetBuilder( + id: 'patrol_radius_field', + builder: (controller) { + final patrolType = + controller.patrolTypeController.text.toLowerCase(); + if (patrolType.isEmpty) return const SizedBox.shrink(); + + final String rangeText = _getRecommendedRangeText(patrolType); + + return Padding( + padding: const EdgeInsets.only(left: 8.0, bottom: 16.0), + child: Text( + 'Recommended range: $rangeText', + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.grey[400] : Colors.grey[600], + fontStyle: FontStyle.italic, + ), + ), + ); + }, + ), + ], + ); + } + + // Get recommended range text based on patrol type + String _getRecommendedRangeText(String patrolType) { + switch (patrolType) { + case 'car': + return '5000-8000m'; + case 'motorcycle': + return '3000-5000m'; + case 'foot': + return '500-1500m'; + case 'drone': + return '2000-4000m'; + case 'mixed': + return '2000-6000m'; + default: + return ''; + } + } + + // Validate radius against recommended range + String? _validateRadiusRange(String patrolType, double radius) { + switch (patrolType) { + case 'car': + if (radius < 5000) { + return 'Car patrols should have at least 5000m radius'; + } else if (radius > 8000) { + return 'Car patrol radius should not exceed 8000m'; + } + break; + case 'motorcycle': + if (radius < 3000) { + return 'Motorcycle patrols should have at least 3000m radius'; + } else if (radius > 5000) { + return 'Motorcycle patrol radius should not exceed 5000m'; + } + break; + case 'foot': + if (radius < 500) { + return 'Foot patrols should have at least 500m radius'; + } else if (radius > 1500) { + return 'Foot patrol radius should not exceed 1500m'; + } + break; + case 'drone': + if (radius < 2000) { + return 'Drone patrols should have at least 2000m radius'; + } else if (radius > 4000) { + return 'Drone patrol radius should not exceed 4000m'; + } + break; + case 'mixed': + if (radius < 2000) { + return 'Mixed patrols should have at least 2000m radius'; + } else if (radius > 6000) { + return 'Mixed patrol radius should not exceed 6000m'; + } + break; + } + return null; + } + + // Build summary of the patrol unit being created + Widget _buildPatrolUnitSummary(OfficerInfoController controller) { + final isDark = THelperFunctions.isDarkMode(Get.context!); + final theme = Theme.of(Get.context!); + + return GetBuilder( + id: 'patrol_summary', + builder: (controller) { + final name = controller.patrolNameController.text; + final type = controller.patrolTypeController.text; + final radius = controller.patrolRadiusController.text; + final memberCount = controller.memberCountController.text; + final category = controller.categoryController.text; + final status = controller.patrolStatusController.text; + + if (name.isEmpty || + type.isEmpty || + radius.isEmpty || + memberCount.isEmpty) { + return const SizedBox.shrink(); + } + + // Generate a preview ID based on the input + final String typeCode = type.isNotEmpty ? type[0].toUpperCase() : ''; + final String unitCode = + controller.unitIdController.text.isNotEmpty + ? controller.unitIdController.text.substring( + controller.unitIdController.text.length - 2, + ) + : 'XX'; + + final int rawSequence = DateTime.now().millisecondsSinceEpoch % 100000; + final String sequence = rawSequence + .toString() + .padLeft(2, '0') + .substring(0, rawSequence.toString().length.clamp(2, 5)); + + final previewId = 'PU-$typeCode$unitCode$sequence'; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: + isDark + ? Colors.blueGrey[800]!.withOpacity(0.3) + : Colors.blueGrey[50]!.withOpacity(0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isDark ? Colors.blueGrey[700]! : Colors.blueGrey[200]!, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Patrol Unit Preview', + style: theme.textTheme.titleMedium!.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + + _buildSummaryRow('ID', previewId), + _buildSummaryRow('Name', name), + _buildSummaryRow( + 'Type', + type.isNotEmpty + ? type[0].toUpperCase() + type.substring(1) + : '', + ), + _buildSummaryRow( + 'Status', + status.isNotEmpty + ? status[0].toUpperCase() + status.substring(1) + : '', + ), + _buildSummaryRow('Radius', '$radius meters'), + _buildSummaryRow('Members', memberCount), + _buildSummaryRow( + 'Category', + category.isNotEmpty + ? category[0].toUpperCase() + category.substring(1) + : '', + ), + _buildSummaryRow('Unit', controller.selectedUnitName.value), + ], + ), + ); + }, + ); + } + + // Helper to build summary row + Widget _buildSummaryRow(String label, String value) { + if (value.isEmpty) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 80, + child: Text( + '$label:', + style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14), + ), + ), + Expanded(child: Text(value, style: const TextStyle(fontSize: 14))), + ], + ), + ); + } + + // Helper method to build a selection mode tab + Widget _buildModeTab( + PatrolUnitController controller, + PatrolSelectionMode mode, + String label, + IconData icon, + ) { + final bool isSelected = controller.patrolSelectionMode.value == mode; + final isDark = THelperFunctions.isDarkMode(Get.context!); + + // define the color based on selection and theme + final boxDecorationColor = + isSelected + ? (isDark + ? TColors.accent.withOpacity(0.1) + : TColors.primary.withOpacity(0.1)) + : Colors.transparent; + + final color = + isSelected + ? (isDark ? TColors.accent : TColors.primary) + : isDark + ? TColors.accent.withOpacity(0.3) + : TColors.primary.withOpacity(0.3); + + return Expanded( + child: InkWell( + onTap: () => controller.setPatrolSelectionMode(mode), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: boxDecorationColor, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color), + ), + child: Column( + children: [ + Icon(icon, color: color), + const SizedBox(height: 4), + Text(label, style: TextStyle(color: color)), + ], + ), + ), + ), + ); + } } diff --git a/sigap-mobile/lib/src/features/daily-ops/controllers/patrol_unit_controller.dart b/sigap-mobile/lib/src/features/daily-ops/controllers/patrol_unit_controller.dart new file mode 100644 index 0000000..c543e89 --- /dev/null +++ b/sigap-mobile/lib/src/features/daily-ops/controllers/patrol_unit_controller.dart @@ -0,0 +1,462 @@ +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/repositories/patrol_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 PatrolUnitTypeExtended { car, motorcycle, foot, drone, mixed } + +enum PatrolUnitStatus { active, standby, maintenance, patrol, onDuty, offDuty } + +enum PatrolSelectionMode { individual, group, createNew } + +class PatrolUnitController extends GetxController { + // Singleton instance + static PatrolUnitController get instance => Get.find(); + + // Data states + final RxList availablePatrolUnits = [].obs; + final RxBool isLoadingPatrolUnits = false.obs; + final RxString selectedPatrolUnitName = ''.obs; + final Rx selectedPatrolType = + PatrolUnitTypeExtended.car.obs; + final Rx patrolSelectionMode = + PatrolSelectionMode.individual.obs; + + // Controllers for selected patrol unit + final patrolUnitIdController = TextEditingController(); + + // Controllers for new patrol unit with enhanced fields + final patrolNameController = TextEditingController(); + final patrolTypeController = TextEditingController(text: 'car'); + final patrolRadiusController = TextEditingController(text: '6500'); + final patrolStatusController = TextEditingController(text: 'active'); + final memberCountController = TextEditingController(text: '1'); + final categoryController = TextEditingController(text: 'individual'); + + // Unit ID for current selection + final unitIdController = TextEditingController(); + final RxString selectedUnitName = ''.obs; + + // Error states for patrol unit fields + final RxString patrolUnitIdError = ''.obs; + final RxString patrolNameError = ''.obs; + final RxString patrolTypeError = ''.obs; + final RxString patrolRadiusError = ''.obs; + final RxString patrolStatusError = ''.obs; + final RxString memberCountError = ''.obs; + final RxString categoryError = ''.obs; + + // Logger instance + final Logger logger = Logger(); + + // Repository + late final PatrolUnitRepository patrolUnitRepository; + + // Flag for creation mode + final RxBool isCreatingNewPatrolUnit = false.obs; + + @override + void onInit() { + super.onInit(); + initRepository(); + } + + void initRepository() { + patrolUnitRepository = Get.find(); + logger.i('PatrolUnitRepository initialized'); + } + + // Fetch patrol units by unit ID + Future getPatrolUnitsByUnitId(String unitId) async { + try { + isLoadingPatrolUnits.value = true; + availablePatrolUnits.clear(); + + final patrolUnits = await patrolUnitRepository.getPatrolUnitsByUnitId( + unitId, + ); + availablePatrolUnits.value = patrolUnits; + + logger.i( + 'Fetched ${patrolUnits.length} patrol units for unit ID: $unitId', + ); + 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'); + } + } + + // 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; + + // Reset patrol unit selection when switching between individual and group + patrolUnitIdController.clear(); + selectedPatrolUnitName.value = ''; + } + + update(['patrol_mode_tabs']); + } + + // Set patrol unit type + void setPatrolUnitType(PatrolUnitTypeExtended type) { + selectedPatrolType.value = type; + patrolTypeController.text = type.name; + + // Update the recommended radius based on type + switch (type) { + case PatrolUnitTypeExtended.car: + patrolRadiusController.text = '6500'; + break; + case PatrolUnitTypeExtended.motorcycle: + patrolRadiusController.text = '4000'; + break; + case PatrolUnitTypeExtended.foot: + patrolRadiusController.text = '1000'; + break; + case PatrolUnitTypeExtended.drone: + patrolRadiusController.text = '3000'; + break; + case PatrolUnitTypeExtended.mixed: + patrolRadiusController.text = '4000'; + break; + } + + update(['patrol_type_selector', 'patrol_radius_field', 'patrol_summary']); + } + + // Join an existing patrol unit + void joinPatrolUnit(PatrolUnitModel patrolUnit) { + patrolUnitIdController.text = patrolUnit.id; + selectedPatrolUnitName.value = patrolUnit.name; + + // Determine patrol type from the unit type + if (patrolUnit.type.toLowerCase() == 'car') { + selectedPatrolType.value = PatrolUnitTypeExtended.car; + } else if (patrolUnit.type.toLowerCase() == 'motorcycle') { + selectedPatrolType.value = PatrolUnitTypeExtended.motorcycle; + } else if (patrolUnit.type.toLowerCase() == 'foot') { + selectedPatrolType.value = PatrolUnitTypeExtended.foot; + } else if (patrolUnit.type.toLowerCase() == 'drone') { + selectedPatrolType.value = PatrolUnitTypeExtended.drone; + } else { + selectedPatrolType.value = PatrolUnitTypeExtended.mixed; + } + + update(['patrol_selection']); + } + + // Get available patrol units filtered by type AND category + List getFilteredPatrolUnits() { + // Filter by vehicle type first + var filteredByType = + availablePatrolUnits + .where( + (unit) => + unit.type.toLowerCase() == + selectedPatrolType.value.name.toLowerCase(), + ) + .toList(); + + // Then filter by category based on selection mode + if (patrolSelectionMode.value == PatrolSelectionMode.individual) { + return filteredByType + .where((unit) => unit.category?.toLowerCase() == 'individual') + .toList(); + } else if (patrolSelectionMode.value == PatrolSelectionMode.group) { + return filteredByType + .where((unit) => unit.category?.toLowerCase() == 'group') + .toList(); + } + + // Default to returning all units filtered by type if in create mode + return filteredByType; + } + + // Validate new patrol unit data with enhanced validation + bool validateNewPatrolUnit() { + bool isValid = true; + + // Clear previous errors + clearErrors(); + + // 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 status + if (patrolStatusController.text.isEmpty) { + patrolStatusError.value = 'Status is required'; + isValid = false; + } + + // Validate member count + if (memberCountController.text.isEmpty) { + memberCountError.value = 'Member count is required'; + isValid = false; + } else { + try { + final count = int.parse(memberCountController.text); + if (count <= 0) { + memberCountError.value = 'Must have at least 1 member'; + isValid = false; + } + } catch (e) { + memberCountError.value = 'Invalid member count'; + isValid = false; + } + } + + // Validate category + if (categoryController.text.isEmpty) { + categoryError.value = 'Category is required'; + isValid = false; + } else if (categoryController.text != 'individual' && + categoryController.text != 'group') { + categoryError.value = 'Category must be either individual or group'; + 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; + } else { + // Validate against recommended ranges for each type + final String? rangeError = validateRadiusRange( + patrolTypeController.text.toLowerCase(), + radius, + ); + if (rangeError != null) { + patrolRadiusError.value = rangeError; + isValid = false; + } + } + } catch (e) { + patrolRadiusError.value = 'Invalid radius value'; + isValid = false; + } + } + + return isValid; + } + + // Validate radius against recommended range + String? validateRadiusRange(String patrolType, double radius) { + switch (patrolType) { + case 'car': + if (radius < 5000) + return 'Car patrols should have at least 5000m radius'; + if (radius > 8000) return 'Car patrol radius should not exceed 8000m'; + break; + case 'motorcycle': + if (radius < 3000) + return 'Motorcycle patrols should have at least 3000m radius'; + if (radius > 5000) + return 'Motorcycle patrol radius should not exceed 5000m'; + break; + case 'foot': + if (radius < 500) + return 'Foot patrols should have at least 500m radius'; + if (radius > 1500) return 'Foot patrol radius should not exceed 1500m'; + break; + case 'drone': + if (radius < 2000) + return 'Drone patrols should have at least 2000m radius'; + if (radius > 4000) return 'Drone patrol radius should not exceed 4000m'; + break; + case 'mixed': + if (radius < 2000) + return 'Mixed patrols should have at least 2000m radius'; + if (radius > 6000) return 'Mixed patrol radius should not exceed 6000m'; + break; + } + return null; + } + + // Get recommended range text based on patrol type + String getRecommendedRangeText(String patrolType) { + switch (patrolType) { + case 'car': + return '5000-8000m'; + case 'motorcycle': + return '3000-5000m'; + case 'foot': + return '500-1500m'; + case 'drone': + return '2000-4000m'; + case 'mixed': + return '2000-6000m'; + default: + return ''; + } + } + + // Create a new patrol unit with enhanced fields + 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 { + // Generate a proper ID for the new patrol unit + final String typeCode = + patrolTypeController.text.isNotEmpty + ? patrolTypeController.text[0].toUpperCase() + : 'X'; + + final String unitCode = + unitIdController.text.length >= 2 + ? unitIdController.text.substring( + unitIdController.text.length - 2, + ) + : 'XX'; + + // Generate a sequence number based on current time + final int rawSequence = DateTime.now().millisecondsSinceEpoch % 100000; + final String sequence = rawSequence + .toString() + .padLeft(2, '0') + .substring(0, rawSequence.toString().length.clamp(2, 5)); + + final String patrolUnitId = 'PU-$typeCode$unitCode$sequence'; + + // In a real scenario, we would create the patrol unit in the database + // Here we're simulating a successful creation + + // Create patrol unit model + final newPatrolUnit = PatrolUnitModel( + id: patrolUnitId, + unitId: unitIdController.text, + locationId: + '', // This would come from the location selection or current location + name: patrolNameController.text, + type: patrolTypeController.text, + status: patrolStatusController.text, + radius: double.parse(patrolRadiusController.text), + createdAt: DateTime.now(), + memberCount: int.tryParse(memberCountController.text), + category: categoryController.text, + ); + + // In a real app, we would save this to the database + // final result = await patrolUnitRepository.createPatrolUnit(newPatrolUnit); + + // Update the controller state + patrolUnitIdController.text = patrolUnitId; + selectedPatrolUnitName.value = patrolNameController.text; + + // Simulate success + TLoaders.successSnackBar( + title: 'Success', + message: 'Patrol unit created successfully', + ); + + // Add to available patrol units for immediate display + availablePatrolUnits.add(newPatrolUnit); + + update(['patrol_summary', 'patrol_selection']); + return true; + } catch (error) { + TLoaders.errorSnackBar( + title: 'Error', + message: 'Failed to create patrol unit: $error', + ); + return false; + } + } + + // Clear all error messages + void clearErrors() { + patrolNameError.value = ''; + patrolTypeError.value = ''; + patrolRadiusError.value = ''; + patrolStatusError.value = ''; + memberCountError.value = ''; + categoryError.value = ''; + patrolUnitIdError.value = ''; + } + + // Set the unit info (called from the parent controller) + void setUnitInfo(String unitId, String unitName) { + unitIdController.text = unitId; + selectedUnitName.value = unitName; + + // Fetch patrol units for this unit + getPatrolUnitsByUnitId(unitId); + } + + // Reset all form fields + void resetForm() { + patrolNameController.text = ''; + patrolTypeController.text = 'car'; + patrolRadiusController.text = '6500'; + patrolStatusController.text = 'active'; + memberCountController.text = '1'; + categoryController.text = 'individual'; + patrolUnitIdController.text = ''; + selectedPatrolUnitName.value = ''; + selectedPatrolType.value = PatrolUnitTypeExtended.car; + patrolSelectionMode.value = PatrolSelectionMode.individual; + clearErrors(); + } + + @override + void onClose() { + // Dispose all controllers + patrolUnitIdController.dispose(); + patrolNameController.dispose(); + patrolTypeController.dispose(); + patrolRadiusController.dispose(); + patrolStatusController.dispose(); + memberCountController.dispose(); + categoryController.dispose(); + unitIdController.dispose(); + super.onClose(); + } +} diff --git a/sigap-mobile/lib/src/features/daily-ops/data/models/models/officers_model.dart b/sigap-mobile/lib/src/features/daily-ops/data/models/models/officers_model.dart index 090cc68..ebc5143 100644 --- a/sigap-mobile/lib/src/features/daily-ops/data/models/models/officers_model.dart +++ b/sigap-mobile/lib/src/features/daily-ops/data/models/models/officers_model.dart @@ -178,6 +178,8 @@ class OfficerModel { ); } + + @override String toString() { return 'OfficerModel(id: $id, name: $name, nrp: $nrp)'; diff --git a/sigap-mobile/lib/src/features/daily-ops/data/models/models/patrol_units_model.dart b/sigap-mobile/lib/src/features/daily-ops/data/models/models/patrol_units_model.dart index f4ee918..cbb3bee 100644 --- a/sigap-mobile/lib/src/features/daily-ops/data/models/models/patrol_units_model.dart +++ b/sigap-mobile/lib/src/features/daily-ops/data/models/models/patrol_units_model.dart @@ -1,6 +1,6 @@ +import 'package:sigap/src/features/daily-ops/data/models/models/officers_model.dart'; import 'package:sigap/src/features/daily-ops/data/models/models/units_model.dart'; import 'package:sigap/src/features/map/data/models/models/locations_model.dart'; -import 'package:sigap/src/features/daily-ops/data/models/models/officers_model.dart'; class PatrolUnitModel { final String id; @@ -14,6 +14,8 @@ class PatrolUnitModel { final List? members; final LocationModel? location; final UnitModel? unit; + final String? category; // Added category field (individual/group) + final int? memberCount; // Added member count field PatrolUnitModel({ required this.id, @@ -27,6 +29,8 @@ class PatrolUnitModel { this.members, this.location, this.unit, + this.category, + this.memberCount, }); // Create a PatrolUnitModel instance from a JSON object @@ -54,6 +58,11 @@ class PatrolUnitModel { json['unit'] != null ? UnitModel.fromJson(json['unit'] as Map) : null, + category: json['category'] as String?, // Parse category from JSON + memberCount: + json['member_count'] != null + ? int.parse(json['member_count'].toString()) + : null, // Parse member_count from JSON ); } @@ -69,6 +78,8 @@ class PatrolUnitModel { 'radius': radius, 'created_at': createdAt.toIso8601String(), if (members != null) 'members': members!.map((e) => e.toJson()).toList(), + if (category != null) 'category': category, + if (memberCount != null) 'member_count': memberCount, }; } @@ -85,6 +96,8 @@ class PatrolUnitModel { List? members, LocationModel? location, UnitModel? unit, + String? category, + int? memberCount, }) { return PatrolUnitModel( id: id ?? this.id, @@ -98,6 +111,8 @@ class PatrolUnitModel { members: members ?? this.members, location: location ?? this.location, unit: unit ?? this.unit, + category: category ?? this.category, + memberCount: memberCount ?? this.memberCount, ); } @@ -105,8 +120,14 @@ class PatrolUnitModel { return {'latitude': location?.latitude, 'longitude': location?.longitude}; } + // Calculate member count if it's not provided but members list exists + int get effectiveMemberCount { + if (memberCount != null) return memberCount!; + return members?.length ?? 0; + } + @override String toString() { - return 'PatrolUnitModel(id: $id, name: $name, status: $status)'; + return 'PatrolUnitModel(id: $id, name: $name, status: $status, category: $category, memberCount: $memberCount)'; } } diff --git a/sigap-mobile/lib/src/features/personalization/data/repositories/officers_repository.dart b/sigap-mobile/lib/src/features/personalization/data/repositories/officers_repository.dart index d17265c..5ca95bd 100644 --- a/sigap-mobile/lib/src/features/personalization/data/repositories/officers_repository.dart +++ b/sigap-mobile/lib/src/features/personalization/data/repositories/officers_repository.dart @@ -44,48 +44,28 @@ class OfficerRepository extends GetxController { } // Update officer profile - Future updateOfficerProfile(OfficerModel officer) async { + Future updateOfficer(OfficerModel officer) async { try { if (currentUserId == null) { throw 'User not authenticated'; } - final updatedOfficerData = { - 'name': officer.name, - 'nrp': officer.nrp, - 'rank': officer.rank, - 'position': officer.position, - 'phone': officer.phone, - 'email': officer.email, - 'avatar': officer.avatar, - 'updated_at': DateTime.now().toIso8601String(), - }; + final data = officer.toJson(); - await _supabase + final updatedOfficer = + await _supabase .from('officers') - .update(updatedOfficerData) - .eq('id', currentUserId!); + .update(data) + .eq('id', currentUserId!) + .select() + .maybeSingle(); - // Also update the user's metadata to keep it in sync - final currentMetadata = _supabase.auth.currentUser?.userMetadata ?? {}; - final officerDataInMetadata = currentMetadata['officer_data'] ?? {}; + if (updatedOfficer == null) { + return null; + } - final updatedOfficerMetadata = { - ...officerDataInMetadata, - 'name': officer.name, - 'nrp': officer.nrp, - 'rank': officer.rank, - 'position': officer.position, - 'phone': officer.phone, - }; - - await _supabase.auth.updateUser( - UserAttributes( - data: {...currentMetadata, 'officer_data': updatedOfficerMetadata}, - ), - ); - - return await getOfficerData(); + // updatedOfficer is a List, so we take the first item and convert it + return OfficerModel.fromJson(updatedOfficer); } on PostgrestException catch (error) { throw TExceptions.fromCode(error.code!); } on AuthException catch (e) { diff --git a/sigap-mobile/lib/src/features/personalization/data/repositories/users_repository.dart b/sigap-mobile/lib/src/features/personalization/data/repositories/users_repository.dart index ddd4961..f073c19 100644 --- a/sigap-mobile/lib/src/features/personalization/data/repositories/users_repository.dart +++ b/sigap-mobile/lib/src/features/personalization/data/repositories/users_repository.dart @@ -3,6 +3,7 @@ import 'package:get/get.dart'; import 'package:logger/Logger.dart'; import 'package:sigap/src/cores/services/supabase_service.dart'; import 'package:sigap/src/features/personalization/data/models/models/roles_model.dart'; +import 'package:sigap/src/features/personalization/data/models/models/user_metadata_model.dart'; import 'package:sigap/src/features/personalization/data/models/models/users_model.dart'; import 'package:sigap/src/utils/exceptions/exceptions.dart'; import 'package:sigap/src/utils/exceptions/format_exceptions.dart'; @@ -214,11 +215,12 @@ class UserRepository extends GetxController { final role = RoleModel.fromJson(roleData); - // Update metadata with role information - await updateUserMetadata({ - 'is_officer': role.isOfficer, - 'role_name': role.name, - }); + final metadata = + UserMetadataModel( + isOfficer: role.isOfficer, + roleId: role.id, + ).toAuthMetadataJson(); + await updateUserMetadata(metadata); } on PostgrestException catch (error) { _logger.e('PostgrestException in updateUserRole: ${error.message}'); throw TExceptions.fromCode(error.code ?? 'unknown-error'); 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 7688fec..886c136 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 @@ -55,7 +55,9 @@ class CustomTextField extends StatelessWidget { // Determine the effective fill color final Color effectiveFillColor = - fillColor ?? (isDark ? TColors.dark : TColors.lightContainer); + enabled == false + ? (isDark ? Colors.grey[800]! : Colors.grey[200]!) + : fillColor ?? (isDark ? TColors.dark : TColors.lightContainer); // Get the common input decoration for both cases final inputDecoration = _getInputDecoration( @@ -65,16 +67,26 @@ class CustomTextField extends StatelessWidget { effectiveFillColor, ); + // Style for disabled text + final TextStyle? textStyle = + enabled == false + ? Theme.of(context).textTheme.bodyMedium?.copyWith( + color: isDark ? Colors.grey[500] : Colors.grey[600], + ) + : Theme.of(context).textTheme.bodyMedium; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Label text using theme typography + // Label text with dimmed color if disabled Text( label, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith( + style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w500, + color: + enabled == false + ? (isDark ? Colors.grey[500] : Colors.grey[600]) + : null, ), ), const SizedBox(height: TSizes.sm), @@ -83,7 +95,7 @@ class CustomTextField extends StatelessWidget { if (controller != null) TextFormField( controller: controller, - validator: validator, + validator: enabled == false ? null : validator, keyboardType: keyboardType, textInputAction: textInputAction, maxLines: maxLines, @@ -91,13 +103,13 @@ class CustomTextField extends StatelessWidget { readOnly: readOnly, obscureText: obscureText, onChanged: onChanged, - style: Theme.of(context).textTheme.bodyMedium, + style: textStyle, decoration: inputDecoration, ) else TextFormField( initialValue: initialValue, - validator: validator, + validator: enabled == false ? null : validator, keyboardType: keyboardType, textInputAction: textInputAction, maxLines: maxLines, @@ -105,7 +117,7 @@ class CustomTextField extends StatelessWidget { readOnly: readOnly, obscureText: obscureText, onChanged: onChanged, - style: Theme.of(context).textTheme.bodyMedium, + style: textStyle, decoration: inputDecoration, ), const SizedBox(height: TSizes.spaceBtwInputFields), @@ -119,6 +131,11 @@ class CustomTextField extends StatelessWidget { bool isDark, Color effectiveFillColor, ) { + final borderColor = + enabled == false + ? (isDark ? Colors.grey[700] : Colors.grey[300]) + : Theme.of(context).dividerColor; + return InputDecoration( hintText: hintText, hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( @@ -130,22 +147,49 @@ class CustomTextField extends StatelessWidget { horizontal: TSizes.md, vertical: TSizes.md, ), - prefixIcon: prefixIcon, - suffixIcon: suffixIcon, + prefixIcon: + enabled == false && prefixIcon != null + ? IconTheme( + data: IconThemeData( + color: isDark ? Colors.grey[600] : Colors.grey[400], + ), + child: prefixIcon!, + ) + : prefixIcon, + suffixIcon: + enabled == false && suffixIcon != null + ? IconTheme( + data: IconThemeData( + color: isDark ? Colors.grey[600] : Colors.grey[400], + ), + child: suffixIcon!, + ) + : suffixIcon, filled: true, fillColor: effectiveFillColor, - // Use theme-aware border styling + // Use theme-aware border styling with different styling for disabled state border: OutlineInputBorder( borderRadius: BorderRadius.circular(TSizes.inputFieldRadius), - borderSide: BorderSide(color: Theme.of(context).dividerColor, width: 1), + borderSide: BorderSide(color: borderColor!, width: 1), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(TSizes.inputFieldRadius), - borderSide: BorderSide(color: Theme.of(context).dividerColor, width: 1), + borderSide: BorderSide(color: borderColor, width: 1), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(TSizes.inputFieldRadius), - borderSide: BorderSide(color: effectiveAccentColor, width: 1.5), + borderSide: BorderSide( + color: enabled == false ? borderColor : effectiveAccentColor, + width: enabled == false ? 1 : 1.5, + ), + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(TSizes.inputFieldRadius), + borderSide: BorderSide( + color: borderColor, + width: 1, + style: BorderStyle.solid, + ), ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(TSizes.inputFieldRadius), diff --git a/sigap-mobile/lib/src/utils/constants/colors.dart b/sigap-mobile/lib/src/utils/constants/colors.dart index 3e3d001..adbc825 100644 --- a/sigap-mobile/lib/src/utils/constants/colors.dart +++ b/sigap-mobile/lib/src/utils/constants/colors.dart @@ -33,6 +33,10 @@ class TColors { static const Color error = Color(0xFFEF4444); static const Color success = Color(0xFF38B2AC); static const Color warning = Color(0xFFF59E0B); + static const Color info = Color(0xFF2563EB); // Information state color + static const Color disabled = Color(0xFFB0B0B0); // Disabled state color + static const Color active = Color(0xFF2F2F2F); // Active state color + static const Color inactive = Color(0xFF6B6B6B); // Inactive state color // Neutral Shades static const Color black = Color(0xFF232323); @@ -77,6 +81,9 @@ class TColors { 0xFFFFFFFF, ); // Dark mode card text + + + // Additional colors static const Color transparent = Colors.transparent;