feat: Enhance Patrol Unit Management

- Added new fields `category` and `memberCount` to `PatrolUnitModel` for better categorization and tracking of patrol units.
- Introduced `PatrolUnitController` to manage patrol unit operations, including creation, validation, and fetching of patrol units.
- Implemented enhanced validation for patrol unit creation, ensuring all required fields are correctly filled.
- Updated `updateOfficerProfile` method in `OfficerRepository` to return nullable `OfficerModel`.
- Improved `CustomTextField` widget to handle disabled states with appropriate styling.
- Added new colors for information and disabled states in `TColors`.
- Refactored `UserRepository` to update user metadata with role information using a new `UserMetadataModel`.
This commit is contained in:
vergiLgood1 2025-05-26 18:46:37 +07:00
parent 105d992faa
commit 407233916b
11 changed files with 1925 additions and 897 deletions

View File

@ -85,11 +85,6 @@ class AuthenticationRepository extends GetxController {
final isProfileComplete = final isProfileComplete =
session?.user.userMetadata?['profile_status'] == 'completed'; 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 // Cek lokasi terlebih dahulu
if (await _locationService.isLocationValidForFeature() == false) { if (await _locationService.isLocationValidForFeature() == false) {
_navigateToRoute(AppRoutes.locationWarning); _navigateToRoute(AppRoutes.locationWarning);
@ -674,7 +669,7 @@ class AuthenticationRepository extends GetxController {
// Don't attempt profile completion while already redirecting // Don't attempt profile completion while already redirecting
throw 'Cannot complete profile during redirection. Please try again.'; throw 'Cannot complete profile during redirection. Please try again.';
} }
try { try {
// Convert to UserModel // Convert to UserModel
final userMetadataModel = UserMetadataModel.fromInitUserMetadata( final userMetadataModel = UserMetadataModel.fromInitUserMetadata(
@ -697,7 +692,7 @@ class AuthenticationRepository extends GetxController {
.from('profiles') .from('profiles')
.insert(completeData.viewerData!.toJson()); .insert(completeData.viewerData!.toJson());
} }
// Set redirection flag to ensure we don't navigate before setup is complete // Set redirection flag to ensure we don't navigate before setup is complete
_isRedirecting = true; _isRedirecting = true;
} catch (e) { } catch (e) {

View File

@ -1,94 +1,67 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:logger/logger.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/auth/data/models/kta_model.dart';
import 'package:sigap/src/features/daily-ops/data/models/models/units_model.dart'; import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart';
import 'package:sigap/src/features/daily-ops/data/repositories/patrol_units_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/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/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 { class OfficerInfoController extends GetxController {
// Singleton instance // Singleton instance
static OfficerInfoController get instance => Get.find(); static OfficerInfoController get instance => Get.find();
// Static form key
// final GlobalKey<FormState> formKey = TGlobalFormKey.officerInfo();
final RxBool isFormValid = RxBool(true); final RxBool isFormValid = RxBool(true);
// Data states // Data states
final RxList<UnitModel> availableUnits = <UnitModel>[].obs; final RxList<UnitModel> availableUnits = <UnitModel>[].obs;
final RxList<PatrolUnitModel> availablePatrolUnits = <PatrolUnitModel>[].obs;
final RxBool isLoadingUnits = false.obs; final RxBool isLoadingUnits = false.obs;
final RxBool isLoadingPatrolUnits = false.obs;
final RxString selectedUnitName = ''.obs; final RxString selectedUnitName = ''.obs;
final RxString selectedPatrolUnitName = ''.obs;
// Additional data states for patrol unit configuration // Controllers for officer info
final Rx<PatrolUnitType> selectedPatrolType = PatrolUnitType.car.obs;
final Rx<PatrolSelectionMode> patrolSelectionMode =
PatrolSelectionMode.individual.obs;
final RxBool isCreatingNewPatrolUnit = false.obs;
final RxString newPatrolUnitName = ''.obs;
// Controllers
final nrpController = TextEditingController(); final nrpController = TextEditingController();
final rankController = TextEditingController();
final unitIdController = TextEditingController();
final patrolUnitIdController = TextEditingController();
final nameController = TextEditingController(); final nameController = TextEditingController();
final rankController = TextEditingController();
final positionController = TextEditingController(); final positionController = TextEditingController();
final phoneController = TextEditingController(); final phoneController = TextEditingController();
final emailController = TextEditingController(); final unitIdController = TextEditingController();
final validUntilController = TextEditingController(); final validUntilController = TextEditingController();
final avatarController = TextEditingController();
final qrCodeController = TextEditingController();
final bannedReasonController = TextEditingController();
final bannedUntilController = TextEditingController();
// Controllers for new patrol unit // New fields based on the model
final patrolNameController = TextEditingController(); final placeOfBirthController = TextEditingController();
final patrolTypeController = TextEditingController(); final dateOfBirthController = TextEditingController();
final patrolRadiusController = TextEditingController(
text: '500',
); // Default radius in meters
// Error states // Error states
final RxString nrpError = ''.obs; final RxString nrpError = ''.obs;
final RxString nameError = ''.obs;
final RxString rankError = ''.obs; final RxString rankError = ''.obs;
final RxString unitIdError = ''.obs; final RxString unitIdError = ''.obs;
final RxString patrolUnitIdError = ''.obs;
final RxString nameError = ''.obs;
final RxString positionError = ''.obs; final RxString positionError = ''.obs;
final RxString phoneError = ''.obs; final RxString phoneError = ''.obs;
final RxString emailError = ''.obs;
final RxString validUntilError = ''.obs; final RxString validUntilError = ''.obs;
final RxString avatarError = ''.obs; final RxString placeOfBirthError = ''.obs;
final RxString qrCodeError = ''.obs; final RxString dateOfBirthError = ''.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;
// Logger instance // Logger instance
final Logger logger = Logger(); final Logger logger = Logger();
// Make sure repositories are properly initialized // Unit repository
late final UnitRepository unitRepository; late final UnitRepository unitRepository;
late final PatrolUnitRepository patrolUnitRepository;
// Dropdown open state // Dropdown open state
RxBool isUnitDropdownOpen = false.obs; RxBool isUnitDropdownOpen = false.obs;
// Date selection related
Rx<DateTime?> selectedValidUntil = Rx<DateTime?>(null);
Rx<DateTime?> selectedDateOfBirth = Rx<DateTime?>(null);
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
@ -102,9 +75,7 @@ class OfficerInfoController extends GetxController {
void initRepositories() { void initRepositories() {
// Check if repositories are already registered with GetX // Check if repositories are already registered with GetX
unitRepository = Get.find<UnitRepository>(); unitRepository = Get.find<UnitRepository>();
patrolUnitRepository = Get.find<PatrolUnitRepository>(); logger.i('UnitRepository initialized');
Logger().i('UnitRepository and PatrolUnitRepository initialized');
} }
// Fetch available units with improved error handling // 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 // Handle unit selection
void onUnitSelected(UnitModel unit) { void onUnitSelected(UnitModel unit) {
unitIdController.text = unit.codeUnit; unitIdController.text = unit.codeUnit;
selectedUnitName.value = unit.name; 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 // Set valid until date
void onPatrolUnitSelected(PatrolUnitModel patrolUnit) { void setValidUntilDate(DateTime date) {
patrolUnitIdController.text = patrolUnit.id; selectedValidUntil.value = date;
selectedPatrolUnitName.value = patrolUnit.name; validUntilController.text = formatDate(date);
validUntilError.value = '';
} }
// Set patrol unit type (car or motorcycle) // Set date of birth
void setPatrolUnitType(PatrolUnitType type) { void setDateOfBirth(DateTime date) {
selectedPatrolType.value = type; selectedDateOfBirth.value = date;
patrolTypeController.text = type.name; dateOfBirthController.text = formatDate(date);
dateOfBirthError.value = '';
} }
// Set patrol selection mode (individual, group, or create new) // Format date as yyyy-MM-dd
void setPatrolSelectionMode(PatrolSelectionMode mode) { String formatDate(DateTime date) {
patrolSelectionMode.value = mode; return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
// Reset fields when changing modes // Populate fields from KTA data
if (mode == PatrolSelectionMode.createNew) { void populateFromKta(KtaModel ktaData) {
isCreatingNewPatrolUnit.value = true; if (ktaData.nrp.isNotEmpty && nrpController.text.isEmpty) {
patrolUnitIdController.clear(); nrpController.text = ktaData.nrp;
selectedPatrolUnitName.value = ''; }
} else {
isCreatingNewPatrolUnit.value = false; 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 // Validate the form
bool validateNewPatrolUnit() { bool validate(GlobalKey<FormState>? formKey) {
bool isValid = true; clearErrors();
// Clear previous errors bool isValid = formKey?.currentState?.validate() ?? false;
patrolNameError.value = '';
patrolTypeError.value = '';
patrolRadiusError.value = '';
// Validate patrol unit name // Additional validation for required fields
final nameValidation = TValidators.validateUserInput( if (nrpController.text.isEmpty) {
'Patrol Unit Name', nrpError.value = 'NRP is required';
patrolNameController.text,
50,
);
if (nameValidation != null) {
patrolNameError.value = nameValidation;
isValid = false; isValid = false;
} }
// Validate patrol unit type if (nameController.text.isEmpty) {
if (patrolTypeController.text.isEmpty) { nameError.value = 'Name is required';
patrolTypeError.value = 'Patrol type is required';
isValid = false; 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<bool> createNewPatrolUnit() async {
if (!validateNewPatrolUnit()) {
return false;
}
if (unitIdController.text.isEmpty) { if (unitIdController.text.isEmpty) {
TLoaders.errorSnackBar( unitIdError.value = 'Please select a unit';
title: 'Unit Required', isValid = false;
message: 'Please select a unit before creating a patrol unit',
);
return false;
} }
isFormValid.value = isValid;
return isValid;
}
void updateOfficer(OfficerModel officer, UserMetadataModel metadata) async {
try { try {
// This would typically involve an API call to create the patrol unit TCircularFullScreenLoader.openLoadingDialog();
// For now, we'll just simulate success
// In a real implementation, you would call a repository method to create the patrol unit
// Example of what the real implementation might look like: final isConnected = await NetworkManager.instance.isConnected();
/* if (!isConnected) {
final newPatrolUnit = await patrolUnitRepository.createPatrolUnit( TLoaders.errorSnackBar(
PatrolUnitModel( title: 'No Internet Connection',
id: '', // Will be generated by the backend message: 'Please check your internet connection and try again.',
unitId: unitIdController.text, );
locationId: '', // This might need to be set elsewhere TCircularFullScreenLoader.stopLoading();
name: patrolNameController.text, return;
type: selectedPatrolType.value.name, }
status: 'active', // Default status
radius: double.parse(patrolRadiusController.text), // Validate the form before proceeding
createdAt: DateTime.now(), 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 // final updatedOfficer = await OfficerRepository.instance.updateOfficer(
TLoaders.successSnackBar( // data,
title: 'Success', // );
message: 'Patrol unit created successfully',
); // if (updatedOfficer == null) {
return true; // TLoaders.errorSnackBar(
} catch (error) { // 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( TLoaders.errorSnackBar(
title: 'Error', title: 'Update Failed',
message: 'Failed to create patrol unit: $error', message: 'An error occurred while updating officer information.',
); );
return false;
} }
} }
// Join an existing patrol unit void resetForm() {
void joinPatrolUnit(PatrolUnitModel patrolUnit) { nrpController.clear();
patrolUnitIdController.text = patrolUnit.id; nameController.clear();
selectedPatrolUnitName.value = patrolUnit.name; 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<PatrolUnitModel> 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<FormState> formKey) {
clearErrors(); clearErrors();
if (formKey.currentState?.validate() ?? false) { selectedUnitName.value = '';
return true; selectedValidUntil.value = null;
} selectedDateOfBirth.value = null;
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;
} }
// Clear all error messages
void clearErrors() { void clearErrors() {
nrpError.value = ''; nrpError.value = '';
nameError.value = '';
rankError.value = ''; rankError.value = '';
unitIdError.value = ''; unitIdError.value = '';
patrolUnitIdError.value = '';
nameError.value = '';
positionError.value = ''; positionError.value = '';
phoneError.value = ''; phoneError.value = '';
emailError.value = '';
validUntilError.value = ''; validUntilError.value = '';
avatarError.value = ''; placeOfBirthError.value = '';
qrCodeError.value = ''; dateOfBirthError.value = '';
bannedReasonError.value = '';
bannedUntilError.value = '';
// Clear errors for new patrol unit fields
patrolNameError.value = '';
patrolTypeError.value = '';
patrolRadiusError.value = '';
} }
// Clean up resources
@override @override
void onClose() { void onClose() {
nrpController.dispose(); nrpController.dispose();
rankController.dispose();
unitIdController.dispose();
patrolUnitIdController.dispose();
nameController.dispose(); nameController.dispose();
rankController.dispose();
positionController.dispose(); positionController.dispose();
phoneController.dispose(); phoneController.dispose();
emailController.dispose(); unitIdController.dispose();
validUntilController.dispose(); validUntilController.dispose();
avatarController.dispose(); placeOfBirthController.dispose();
qrCodeController.dispose(); dateOfBirthController.dispose();
bannedReasonController.dispose();
bannedUntilController.dispose();
// Dispose controllers for new patrol unit
patrolNameController.dispose();
patrolTypeController.dispose();
patrolRadiusController.dispose();
super.onClose(); super.onClose();
} }
} }

View File

@ -1,8 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/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/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/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/form/form_section_header.dart';
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart'; import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/colors.dart';
@ -19,6 +20,12 @@ class OfficerInfoStep extends StatelessWidget {
final mainController = Get.find<FormRegistrationController>(); final mainController = Get.find<FormRegistrationController>();
mainController.formKey = formKey; mainController.formKey = formKey;
// Check if KTA data exists and populate fields if available
_populateFieldsFromKta(
controller,
mainController.idCardVerificationController,
);
return Form( return Form(
key: formKey, key: formKey,
child: Column( child: Column(
@ -34,6 +41,7 @@ class OfficerInfoStep extends StatelessWidget {
// NRP field // NRP field
CustomTextField( CustomTextField(
label: 'NRP', label: 'NRP',
enabled: controller.nrpController.text.isEmpty,
controller: controller.nrpController, controller: controller.nrpController,
validator: TValidators.validateNRP, validator: TValidators.validateNRP,
errorText: controller.nrpError.value, errorText: controller.nrpError.value,
@ -45,24 +53,34 @@ class OfficerInfoStep extends StatelessWidget {
controller.nrpError.value = ''; controller.nrpError.value = '';
}, },
), ),
Obx( _buildErrorText(controller.nrpError),
() =>
controller.nrpError.value.isNotEmpty // Name field
? Padding( CustomTextField(
padding: const EdgeInsets.only(top: 8.0), label: 'Full Name',
child: Text( controller: controller.nameController,
controller.nrpError.value, validator:
style: TextStyle(color: Colors.red[700], fontSize: 12), (v) => TValidators.validateUserInput(
), 'Name',
) v,
: const SizedBox.shrink(), 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 // Rank field
CustomTextField( CustomTextField(
label: 'Rank', label: 'Rank',
controller: controller.rankController, controller: controller.rankController,
validator: TValidators.validateRank, validator: (v) => TValidators.validateUserInput('Rank', v, 100),
errorText: controller.rankError.value, errorText: controller.rankError.value,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
hintText: 'e.g., Captain', hintText: 'e.g., Captain',
@ -71,30 +89,13 @@ class OfficerInfoStep extends StatelessWidget {
controller.rankError.value = ''; controller.rankError.value = '';
}, },
), ),
Obx( _buildErrorText(controller.rankError),
() =>
controller.rankError.value.isNotEmpty
? Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
controller.rankError.value,
style: TextStyle(color: Colors.red[700], fontSize: 12),
),
)
: const SizedBox.shrink(),
),
// Position field // Position field
CustomTextField( CustomTextField(
label: 'Position', label: 'Position',
controller: controller.positionController, controller: controller.positionController,
validator: validator: (v) => TValidators.validateUserInput('Position', v, 100),
(v) => TValidators.validateUserInput(
'Position',
v,
100,
required: true,
),
errorText: controller.positionError.value, errorText: controller.positionError.value,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
hintText: 'e.g., Head of Unit', hintText: 'e.g., Head of Unit',
@ -103,18 +104,73 @@ class OfficerInfoStep extends StatelessWidget {
controller.positionError.value = ''; controller.positionError.value = '';
}, },
), ),
Obx( _buildErrorText(controller.positionError),
() =>
controller.positionError.value.isNotEmpty // Phone field
? Padding( CustomTextField(
padding: const EdgeInsets.only(top: 8.0), label: 'Phone Number',
child: Text( controller: controller.phoneController,
controller.positionError.value, validator: TValidators.validatePhoneNumber,
style: TextStyle(color: Colors.red[700], fontSize: 12), errorText: controller.phoneError.value,
), textInputAction: TextInputAction.next,
) keyboardType: TextInputType.phone,
: const SizedBox.shrink(), 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), const SizedBox(height: TSizes.spaceBtwSections),
@ -127,154 +183,142 @@ class OfficerInfoStep extends StatelessWidget {
const SizedBox(height: TSizes.spaceBtwItems), const SizedBox(height: TSizes.spaceBtwItems),
// Unit dropdown // Unit dropdown
_buildUnitDropdown(controller), _buildUnitDropdown(
controller,
mainController.idCardVerificationController,
),
const SizedBox(height: TSizes.spaceBtwSections), const SizedBox(height: TSizes.spaceBtwSections),
// Patrol Unit Section - Simplified to show only current selection and a button to navigate // Note about patrol unit assignment
const FormSectionHeader( Container(
title: 'Patrol Unit', padding: const EdgeInsets.all(12),
subtitle: 'Select or create your patrol unit', decoration: BoxDecoration(
), color: TColors.info.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
const SizedBox(height: TSizes.spaceBtwItems), border: Border.all(color: TColors.info),
),
// Display selected patrol unit (if any) child: Row(
Builder( crossAxisAlignment: CrossAxisAlignment.start,
builder: (context) { children: [
final isDark = Theme.of(context).brightness == Brightness.dark; Icon(Icons.info_outline, color: TColors.info),
final theme = Theme.of(context); const SizedBox(width: 12),
Expanded(
return GetX<OfficerInfoController>( child: Column(
builder: crossAxisAlignment: CrossAxisAlignment.start,
(controller) => Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, Text(
children: [ 'Patrol Unit Assignment',
if (controller.selectedPatrolUnitName.isNotEmpty) style: Theme.of(context).textTheme.bodyLarge?.copyWith(
Container( fontWeight: FontWeight.bold,
padding: const EdgeInsets.all(12), color: TColors.info,
decoration: BoxDecoration(
color: theme.primaryColor.withOpacity(
isDark ? 0.2 : 0.1,
),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.primaryColor),
),
child: Row(
children: [
Icon(
controller.selectedPatrolType.value ==
PatrolUnitType.car
? Icons.directions_car
: Icons.motorcycle,
color: theme.primaryColor,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
'Selected Patrol Unit',
style: TextStyle(
color:
isDark
? Colors.grey[400]
: Colors.grey[600],
fontSize: 12,
),
),
Text(
controller.selectedPatrolUnitName.value,
style: TextStyle(
fontWeight: FontWeight.bold,
color:
isDark
? Colors.white
: Colors.black87,
),
),
],
),
),
],
),
),
const SizedBox(height: 16),
// Configure Patrol Unit button
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed:
() => _navigateToPatrolUnitSelectionScreen(
controller,
),
style: ElevatedButton.styleFrom(
backgroundColor: theme.primaryColor,
foregroundColor:
isDark ? Colors.black : Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
),
icon: Icon(
controller.patrolUnitIdController.text.isEmpty
? Icons.add_circle_outline
: Icons.edit,
),
label: Text(
controller.patrolUnitIdController.text.isEmpty
? 'Configure Patrol Unit'
: 'Change Patrol Unit',
),
),
), ),
),
Obx( const SizedBox(height: 4),
() => Text(
controller.patrolUnitIdError.value.isNotEmpty 'After registration is complete, you will be able to join or create a patrol unit from the app.',
? Padding( style: Theme.of(context).textTheme.bodyMedium,
padding: const EdgeInsets.only(top: 8.0), ),
child: Text( ],
controller.patrolUnitIdError.value, ),
style: TextStyle( ),
color: TColors.error, ],
fontSize: 12, ),
),
),
)
: const SizedBox.shrink(),
),
],
),
);
},
), ),
], ],
), ),
); );
} }
// Navigate to the patrol unit selection screen /// Populates form fields from KTA data if available
void _navigateToPatrolUnitSelectionScreen(OfficerInfoController controller) { void _populateFieldsFromKta(
// Check if a unit is selected first OfficerInfoController controller,
if (controller.unitIdController.text.isEmpty) { IdCardVerificationController mainController,
Get.snackbar( ) {
'Unit Required', // Check if KTA data exists in the main controller
'Please select a unit before configuring patrol unit', final KtaModel? ktaData = mainController.ktaModel.value;
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red.withOpacity(0.1),
colorText: Colors.red,
);
return;
}
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 // Build unit dropdown selection
Widget _buildUnitDropdown(OfficerInfoController controller) { Widget _buildUnitDropdown(
OfficerInfoController controller,
IdCardVerificationController idCardController,
) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -351,6 +395,34 @@ class OfficerInfoStep extends StatelessWidget {
(unit) => unit.codeUnit == controller.unitIdController.text, (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( return Column(
children: [ children: [
// Dropdown Selection Button // Dropdown Selection Button
@ -580,18 +652,7 @@ class OfficerInfoStep extends StatelessWidget {
), ),
// Error message // Error message
if (controller.unitIdError.value.isNotEmpty) _buildErrorText(controller.unitIdError),
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),
], ],
); );
}, },

View File

@ -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<PatrolUnitModel> availablePatrolUnits = <PatrolUnitModel>[].obs;
final RxBool isLoadingPatrolUnits = false.obs;
final RxString selectedPatrolUnitName = ''.obs;
final Rx<PatrolUnitTypeExtended> selectedPatrolType =
PatrolUnitTypeExtended.car.obs;
final Rx<PatrolSelectionMode> 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<PatrolUnitRepository>();
logger.i('PatrolUnitRepository initialized');
}
// Fetch patrol units by unit ID
Future<void> 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<PatrolUnitModel> 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<bool> 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();
}
}

View File

@ -178,6 +178,8 @@ class OfficerModel {
); );
} }
@override @override
String toString() { String toString() {
return 'OfficerModel(id: $id, name: $name, nrp: $nrp)'; return 'OfficerModel(id: $id, name: $name, nrp: $nrp)';

View File

@ -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/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/map/data/models/models/locations_model.dart';
import 'package:sigap/src/features/daily-ops/data/models/models/officers_model.dart';
class PatrolUnitModel { class PatrolUnitModel {
final String id; final String id;
@ -14,6 +14,8 @@ class PatrolUnitModel {
final List<OfficerModel>? members; final List<OfficerModel>? members;
final LocationModel? location; final LocationModel? location;
final UnitModel? unit; final UnitModel? unit;
final String? category; // Added category field (individual/group)
final int? memberCount; // Added member count field
PatrolUnitModel({ PatrolUnitModel({
required this.id, required this.id,
@ -27,6 +29,8 @@ class PatrolUnitModel {
this.members, this.members,
this.location, this.location,
this.unit, this.unit,
this.category,
this.memberCount,
}); });
// Create a PatrolUnitModel instance from a JSON object // Create a PatrolUnitModel instance from a JSON object
@ -54,6 +58,11 @@ class PatrolUnitModel {
json['unit'] != null json['unit'] != null
? UnitModel.fromJson(json['unit'] as Map<String, dynamic>) ? UnitModel.fromJson(json['unit'] as Map<String, dynamic>)
: null, : 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, 'radius': radius,
'created_at': createdAt.toIso8601String(), 'created_at': createdAt.toIso8601String(),
if (members != null) 'members': members!.map((e) => e.toJson()).toList(), 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<OfficerModel>? members, List<OfficerModel>? members,
LocationModel? location, LocationModel? location,
UnitModel? unit, UnitModel? unit,
String? category,
int? memberCount,
}) { }) {
return PatrolUnitModel( return PatrolUnitModel(
id: id ?? this.id, id: id ?? this.id,
@ -98,6 +111,8 @@ class PatrolUnitModel {
members: members ?? this.members, members: members ?? this.members,
location: location ?? this.location, location: location ?? this.location,
unit: unit ?? this.unit, 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}; 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 @override
String toString() { String toString() {
return 'PatrolUnitModel(id: $id, name: $name, status: $status)'; return 'PatrolUnitModel(id: $id, name: $name, status: $status, category: $category, memberCount: $memberCount)';
} }
} }

View File

@ -44,48 +44,28 @@ class OfficerRepository extends GetxController {
} }
// Update officer profile // Update officer profile
Future<OfficerModel> updateOfficerProfile(OfficerModel officer) async { Future<OfficerModel?> updateOfficer(OfficerModel officer) async {
try { try {
if (currentUserId == null) { if (currentUserId == null) {
throw 'User not authenticated'; throw 'User not authenticated';
} }
final updatedOfficerData = { final data = officer.toJson();
'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(),
};
await _supabase final updatedOfficer =
await _supabase
.from('officers') .from('officers')
.update(updatedOfficerData) .update(data)
.eq('id', currentUserId!); .eq('id', currentUserId!)
.select()
.maybeSingle();
// Also update the user's metadata to keep it in sync if (updatedOfficer == null) {
final currentMetadata = _supabase.auth.currentUser?.userMetadata ?? {}; return null;
final officerDataInMetadata = currentMetadata['officer_data'] ?? {}; }
final updatedOfficerMetadata = { // updatedOfficer is a List, so we take the first item and convert it
...officerDataInMetadata, return OfficerModel.fromJson(updatedOfficer);
'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();
} on PostgrestException catch (error) { } on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code!); throw TExceptions.fromCode(error.code!);
} on AuthException catch (e) { } on AuthException catch (e) {

View File

@ -3,6 +3,7 @@ import 'package:get/get.dart';
import 'package:logger/Logger.dart'; import 'package:logger/Logger.dart';
import 'package:sigap/src/cores/services/supabase_service.dart'; import 'package:sigap/src/cores/services/supabase_service.dart';
import 'package:sigap/src/features/personalization/data/models/models/roles_model.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/features/personalization/data/models/models/users_model.dart';
import 'package:sigap/src/utils/exceptions/exceptions.dart'; import 'package:sigap/src/utils/exceptions/exceptions.dart';
import 'package:sigap/src/utils/exceptions/format_exceptions.dart'; import 'package:sigap/src/utils/exceptions/format_exceptions.dart';
@ -214,11 +215,12 @@ class UserRepository extends GetxController {
final role = RoleModel.fromJson(roleData); final role = RoleModel.fromJson(roleData);
// Update metadata with role information final metadata =
await updateUserMetadata({ UserMetadataModel(
'is_officer': role.isOfficer, isOfficer: role.isOfficer,
'role_name': role.name, roleId: role.id,
}); ).toAuthMetadataJson();
await updateUserMetadata(metadata);
} on PostgrestException catch (error) { } on PostgrestException catch (error) {
_logger.e('PostgrestException in updateUserRole: ${error.message}'); _logger.e('PostgrestException in updateUserRole: ${error.message}');
throw TExceptions.fromCode(error.code ?? 'unknown-error'); throw TExceptions.fromCode(error.code ?? 'unknown-error');

View File

@ -55,7 +55,9 @@ class CustomTextField extends StatelessWidget {
// Determine the effective fill color // Determine the effective fill color
final Color effectiveFillColor = 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 // Get the common input decoration for both cases
final inputDecoration = _getInputDecoration( final inputDecoration = _getInputDecoration(
@ -65,16 +67,26 @@ class CustomTextField extends StatelessWidget {
effectiveFillColor, 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( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Label text using theme typography // Label text with dimmed color if disabled
Text( Text(
label, label,
style: Theme.of( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
context,
).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color:
enabled == false
? (isDark ? Colors.grey[500] : Colors.grey[600])
: null,
), ),
), ),
const SizedBox(height: TSizes.sm), const SizedBox(height: TSizes.sm),
@ -83,7 +95,7 @@ class CustomTextField extends StatelessWidget {
if (controller != null) if (controller != null)
TextFormField( TextFormField(
controller: controller, controller: controller,
validator: validator, validator: enabled == false ? null : validator,
keyboardType: keyboardType, keyboardType: keyboardType,
textInputAction: textInputAction, textInputAction: textInputAction,
maxLines: maxLines, maxLines: maxLines,
@ -91,13 +103,13 @@ class CustomTextField extends StatelessWidget {
readOnly: readOnly, readOnly: readOnly,
obscureText: obscureText, obscureText: obscureText,
onChanged: onChanged, onChanged: onChanged,
style: Theme.of(context).textTheme.bodyMedium, style: textStyle,
decoration: inputDecoration, decoration: inputDecoration,
) )
else else
TextFormField( TextFormField(
initialValue: initialValue, initialValue: initialValue,
validator: validator, validator: enabled == false ? null : validator,
keyboardType: keyboardType, keyboardType: keyboardType,
textInputAction: textInputAction, textInputAction: textInputAction,
maxLines: maxLines, maxLines: maxLines,
@ -105,7 +117,7 @@ class CustomTextField extends StatelessWidget {
readOnly: readOnly, readOnly: readOnly,
obscureText: obscureText, obscureText: obscureText,
onChanged: onChanged, onChanged: onChanged,
style: Theme.of(context).textTheme.bodyMedium, style: textStyle,
decoration: inputDecoration, decoration: inputDecoration,
), ),
const SizedBox(height: TSizes.spaceBtwInputFields), const SizedBox(height: TSizes.spaceBtwInputFields),
@ -119,6 +131,11 @@ class CustomTextField extends StatelessWidget {
bool isDark, bool isDark,
Color effectiveFillColor, Color effectiveFillColor,
) { ) {
final borderColor =
enabled == false
? (isDark ? Colors.grey[700] : Colors.grey[300])
: Theme.of(context).dividerColor;
return InputDecoration( return InputDecoration(
hintText: hintText, hintText: hintText,
hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
@ -130,22 +147,49 @@ class CustomTextField extends StatelessWidget {
horizontal: TSizes.md, horizontal: TSizes.md,
vertical: TSizes.md, vertical: TSizes.md,
), ),
prefixIcon: prefixIcon, prefixIcon:
suffixIcon: suffixIcon, 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, filled: true,
fillColor: effectiveFillColor, fillColor: effectiveFillColor,
// Use theme-aware border styling // Use theme-aware border styling with different styling for disabled state
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius), borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
borderSide: BorderSide(color: Theme.of(context).dividerColor, width: 1), borderSide: BorderSide(color: borderColor!, width: 1),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius), borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
borderSide: BorderSide(color: Theme.of(context).dividerColor, width: 1), borderSide: BorderSide(color: borderColor, width: 1),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius), 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( errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius), borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),

View File

@ -33,6 +33,10 @@ class TColors {
static const Color error = Color(0xFFEF4444); static const Color error = Color(0xFFEF4444);
static const Color success = Color(0xFF38B2AC); static const Color success = Color(0xFF38B2AC);
static const Color warning = Color(0xFFF59E0B); 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 // Neutral Shades
static const Color black = Color(0xFF232323); static const Color black = Color(0xFF232323);
@ -77,6 +81,9 @@ class TColors {
0xFFFFFFFF, 0xFFFFFFFF,
); // Dark mode card text ); // Dark mode card text
// Additional colors // Additional colors
static const Color transparent = Colors.transparent; static const Color transparent = Colors.transparent;