feat: Enhance selfie verification step with navigation buttons and validation
- Added StepNavigationButtons to SelfieVerificationStep for improved navigation. - Implemented validation logic to ensure selfie verification is completed before proceeding. - Updated AuthButton to use theme colors for better consistency. - Modified OfficerModel to allow nullable fields and added new fields for enhanced data handling. - Set default role to Viewer in RoleSelectionController. - Updated role selection screen with new asset for improved UI. - Enhanced UserMetadataModel with new methods for updating officer and viewer data. - Added logging to OfficerRepository and UserRepository for better debugging. - Improved image uploader error overlay with a blurred background for better visibility. - Added support for SVG images in StateScreen widget. - Introduced custom input decoration in CustomTextField for more flexibility. - Reduced total steps for officers from 4 to 3 in TNum constants. - Fixed schema for patrol_units in Prisma to ensure correct field definitions. - Created StepNavigationButtons widget for reusable navigation controls across steps.
This commit is contained in:
parent
407233916b
commit
569e1d6049
|
@ -942,12 +942,12 @@ class AzureOCRService {
|
|||
// First check for "Jabatan:" pattern explicitly
|
||||
for (int i = 0; i < allLines.length; i++) {
|
||||
String line = allLines[i].toLowerCase();
|
||||
if (line.contains('jabatan')) {
|
||||
if (line.contains('pangkat')) {
|
||||
if (line.contains(':')) {
|
||||
String position = line.split(':')[1].trim();
|
||||
extractedInfo['jabatan'] = _normalizeCase(position);
|
||||
extractedInfo['pangkat'] = _normalizeCase(position);
|
||||
print(
|
||||
'Found position from "jabatan:" pattern: ${extractedInfo['jabatan']}',
|
||||
'Found position from "jabatan:" pattern: ${extractedInfo['pangkat']}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
@ -955,9 +955,9 @@ class AzureOCRService {
|
|||
// Check next line
|
||||
if (i + 1 < allLines.length) {
|
||||
String nextLine = allLines[i + 1].trim();
|
||||
extractedInfo['jabatan'] = _normalizeCase(nextLine);
|
||||
extractedInfo['pangkat'] = _normalizeCase(nextLine);
|
||||
print(
|
||||
'Found position from line after "jabatan": ${extractedInfo['jabatan']}',
|
||||
'Found position from line after "jabatan": ${extractedInfo['pangkat']}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
@ -988,8 +988,8 @@ class AzureOCRService {
|
|||
// Check if the entire line is a position
|
||||
for (String position in commonPositions) {
|
||||
if (line == position) {
|
||||
extractedInfo['jabatan'] = position;
|
||||
print('Found exact position match: ${extractedInfo['jabatan']}');
|
||||
extractedInfo['pangkat'] = position;
|
||||
print('Found exact position match: ${extractedInfo['pangkat']}');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@ -998,8 +998,8 @@ class AzureOCRService {
|
|||
for (String position in commonPositions) {
|
||||
if (line.contains(position)) {
|
||||
// Extract just the position part (this is more complex for real cards)
|
||||
extractedInfo['jabatan'] = position;
|
||||
print('Found position in line: ${extractedInfo['jabatan']}');
|
||||
extractedInfo['pangkat'] = position;
|
||||
print('Found position in line: ${extractedInfo['pangkat']}');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@ -1008,7 +1008,7 @@ class AzureOCRService {
|
|||
// Special handling for the sample data provided
|
||||
if (allLines.length >= 4 &&
|
||||
allLines[3].trim().toUpperCase() == 'BRIGADIR') {
|
||||
extractedInfo['jabatan'] = 'BRIGADIR';
|
||||
extractedInfo['pangkat'] = 'BRIGADIR';
|
||||
print('Found position (BRIGADIR) at line 3');
|
||||
return;
|
||||
}
|
||||
|
@ -1018,8 +1018,8 @@ class AzureOCRService {
|
|||
String line = allLines[i].trim().toUpperCase();
|
||||
for (String position in commonPositions) {
|
||||
if (line == position || line.contains(position)) {
|
||||
extractedInfo['jabatan'] = position;
|
||||
print('Found position in full scan: ${extractedInfo['jabatan']}');
|
||||
extractedInfo['pangkat'] = position;
|
||||
print('Found position in full scan: ${extractedInfo['pangkat']}');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,7 +42,7 @@ class SignInController extends GetxController {
|
|||
|
||||
// Navigate to sign up screen
|
||||
void goToSignUp() {
|
||||
Get.toNamed(AppRoutes.signupWithRole);
|
||||
Get.toNamed(AppRoutes.roleSelection);
|
||||
}
|
||||
|
||||
// Clear error messages
|
||||
|
|
|
@ -41,15 +41,23 @@ class FormRegistrationController extends GetxController {
|
|||
late final OfficerInfoController? officerInfoController;
|
||||
late final UnitInfoController? unitInfoController;
|
||||
|
||||
late GlobalKey<FormState> formKey;
|
||||
// Current step in the registration process
|
||||
final RxInt currentStep = 0.obs;
|
||||
|
||||
// Total steps based on role
|
||||
int get totalSteps =>
|
||||
selectedRole.value?.isOfficer ?? false
|
||||
? TNum
|
||||
.totalStepOfficer // 3 steps for officers
|
||||
: TNum.totalStepViewer; // 4 steps for viewers
|
||||
|
||||
final storage = GetStorage();
|
||||
|
||||
// Current step index
|
||||
final RxInt currentStep = 0.obs;
|
||||
// Loading state for form operations
|
||||
final RxBool isLoading = false.obs;
|
||||
|
||||
// Total number of steps (depends on role)
|
||||
late final int totalSteps;
|
||||
// Form key for validation
|
||||
GlobalKey<FormState>? formKey;
|
||||
|
||||
// User metadata model (kept for backward compatibility)
|
||||
final Rx<UserMetadataModel> userMetadata = UserMetadataModel().obs;
|
||||
|
@ -61,9 +69,6 @@ class FormRegistrationController extends GetxController {
|
|||
// Officer data (kept for backward compatibility)
|
||||
final Rx<OfficerModel?> officerModel = Rx<OfficerModel?>(null);
|
||||
|
||||
// Loading state
|
||||
final RxBool isLoading = false.obs;
|
||||
|
||||
// Form submission states
|
||||
final RxBool isSubmitting = RxBool(false);
|
||||
final RxString submitMessage = RxString('');
|
||||
|
@ -308,11 +313,9 @@ class FormRegistrationController extends GetxController {
|
|||
if (isOfficer) {
|
||||
officerInfoController = Get.find<OfficerInfoController>();
|
||||
unitInfoController = Get.find<UnitInfoController>();
|
||||
totalSteps = TNum.totalStepOfficer;
|
||||
} else {
|
||||
officerInfoController = null;
|
||||
unitInfoController = null;
|
||||
totalSteps = TNum.totalStepViewer;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -562,196 +565,59 @@ class FormRegistrationController extends GetxController {
|
|||
// Get step titles based on role
|
||||
List<String> getStepTitles() {
|
||||
if (selectedRole.value?.isOfficer ?? false) {
|
||||
return ['Personal', 'ID Card', 'Selfie', 'Officer Info', 'Unit Info'];
|
||||
// Officer steps - no personal info
|
||||
return ['ID Card', 'Selfie', 'Officer Info'];
|
||||
} else {
|
||||
return ['Personal', 'ID Card', 'Selfie', 'Identity'];
|
||||
// Viewer steps - includes personal info
|
||||
return ['Personal Info', 'ID Card', 'Selfie', 'Verify'];
|
||||
}
|
||||
}
|
||||
|
||||
// Get registration data for a specific step
|
||||
T? getStepData<T>() {
|
||||
switch (T) {
|
||||
case PersonalInfoData:
|
||||
return registrationData.value.personalInfo as T?;
|
||||
case IdCardVerificationData:
|
||||
return registrationData.value.idCardVerification as T?;
|
||||
case SelfieVerificationData:
|
||||
return registrationData.value.selfieVerification as T?;
|
||||
case IdentityVerificationData:
|
||||
return registrationData.value.identityVerification as T?;
|
||||
case OfficerInfoData:
|
||||
return registrationData.value.officerInfo as T?;
|
||||
case UnitInfoData:
|
||||
return registrationData.value.unitInfo as T?;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Update specific step data
|
||||
void updateStepData<T>(T data) {
|
||||
switch (T) {
|
||||
case PersonalInfoData:
|
||||
registrationData.value = registrationData.value.copyWith(
|
||||
personalInfo: data as PersonalInfoData,
|
||||
);
|
||||
break;
|
||||
case IdCardVerificationData:
|
||||
registrationData.value = registrationData.value.copyWith(
|
||||
idCardVerification: data as IdCardVerificationData,
|
||||
);
|
||||
break;
|
||||
case SelfieVerificationData:
|
||||
registrationData.value = registrationData.value.copyWith(
|
||||
selfieVerification: data as SelfieVerificationData,
|
||||
);
|
||||
break;
|
||||
case IdentityVerificationData:
|
||||
registrationData.value = registrationData.value.copyWith(
|
||||
identityVerification: data as IdentityVerificationData,
|
||||
);
|
||||
break;
|
||||
case OfficerInfoData:
|
||||
registrationData.value = registrationData.value.copyWith(
|
||||
officerInfo: data as OfficerInfoData,
|
||||
);
|
||||
break;
|
||||
case UnitInfoData:
|
||||
registrationData.value = registrationData.value.copyWith(
|
||||
unitInfo: data as UnitInfoData,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate current step
|
||||
bool validateCurrentStep() {
|
||||
switch (currentStep.value) {
|
||||
case 0:
|
||||
return personalInfoController.validate(formKey);
|
||||
case 1:
|
||||
return idCardVerificationController.validate();
|
||||
case 2:
|
||||
return selfieVerificationController.isMatchWithIDCard.value;
|
||||
case 3:
|
||||
return selectedRole.value?.isOfficer == true
|
||||
? officerInfoController!.validate(formKey)
|
||||
: identityController.validate(formKey);
|
||||
case 4:
|
||||
return selectedRole.value?.isOfficer == true
|
||||
? unitInfoController!.validate(formKey)
|
||||
: true;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Go to next step
|
||||
void nextStep() {
|
||||
// Special case for step 1 (ID Card step)
|
||||
if (currentStep.value == 1) {
|
||||
// Log step status
|
||||
Logger().d(
|
||||
'ID Card step: confirmStatus=${idCardVerificationController.hasConfirmedIdCard.value}',
|
||||
);
|
||||
|
||||
// Ensure ID card is confirmed before allowing to proceed
|
||||
if (!idCardVerificationController.hasConfirmedIdCard.value) {
|
||||
// Show a message that user needs to confirm the ID card first
|
||||
TLoaders.errorSnackBar(
|
||||
title: 'Action Required',
|
||||
message: 'Please confirm your ID card image before proceeding.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass data and proceed
|
||||
// passIdCardDataToNextStep();
|
||||
currentStep.value++; // Directly increment step
|
||||
return;
|
||||
}
|
||||
// Special case for step 2 (Selfie Verification step)
|
||||
else if (currentStep.value == 2) {
|
||||
// Log step status
|
||||
Logger().d(
|
||||
'Selfie step: confirmStatus=${selfieVerificationController.hasConfirmedSelfie.value}',
|
||||
);
|
||||
|
||||
// Ensure selfie is confirmed before allowing to proceed
|
||||
if (!selfieVerificationController.hasConfirmedSelfie.value) {
|
||||
// Show a message that user needs to confirm the selfie first
|
||||
TLoaders.errorSnackBar(
|
||||
title: 'Action Required',
|
||||
message: 'Please confirm your selfie image before proceeding.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Proceed to next step
|
||||
currentStep.value++; // Directly increment step
|
||||
return;
|
||||
}
|
||||
|
||||
// For all other steps, perform standard validation
|
||||
if (!validateCurrentStep()) return;
|
||||
|
||||
// Proceed to next step
|
||||
if (currentStep.value < totalSteps - 1) {
|
||||
// Fixed missing parenthesis
|
||||
// Navigate to the next step
|
||||
void nextStep() async {
|
||||
// Validate the current form first
|
||||
if (formKey?.currentState?.validate() ?? false) {
|
||||
// If this is the last step, submit the form
|
||||
if (currentStep.value == totalSteps - 1) {
|
||||
await submitRegistration();
|
||||
} else {
|
||||
// Otherwise, go to the next step
|
||||
currentStep.value++;
|
||||
} else {
|
||||
// submitForm();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void clearPreviousStepErrors() {
|
||||
switch (currentStep.value) {
|
||||
case 0:
|
||||
personalInfoController.clearErrors();
|
||||
break;
|
||||
case 1:
|
||||
idCardVerificationController.clearErrors();
|
||||
break;
|
||||
case 2:
|
||||
selfieVerificationController.clearErrors();
|
||||
break;
|
||||
case 3:
|
||||
if (selectedRole.value?.isOfficer == true) {
|
||||
officerInfoController?.clearErrors();
|
||||
} else {
|
||||
identityController.clearErrors();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Go to previous step
|
||||
// Navigate to the previous step
|
||||
void previousStep() {
|
||||
if (currentStep.value > 0) {
|
||||
// Clear previous step errors
|
||||
clearPreviousStepErrors();
|
||||
|
||||
// Decrement step
|
||||
currentStep.value--;
|
||||
}
|
||||
}
|
||||
|
||||
// Go to specific step
|
||||
// Go to a specific step
|
||||
void goToStep(int step) {
|
||||
if (step >= 0 && step < totalSteps) {
|
||||
// Only allow going to a step if all previous steps are valid
|
||||
bool canProceed = true;
|
||||
for (int i = 0; i < step; i++) {
|
||||
currentStep.value = i;
|
||||
if (!validateCurrentStep()) {
|
||||
canProceed = false;
|
||||
break;
|
||||
currentStep.value = step;
|
||||
}
|
||||
}
|
||||
|
||||
if (canProceed) {
|
||||
currentStep.value = step;
|
||||
}
|
||||
// Submit registration data
|
||||
Future<bool> submitRegistration() async {
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
// Your registration submission logic here
|
||||
await Future.delayed(const Duration(seconds: 2)); // Simulate API call
|
||||
|
||||
// Handle successful registration
|
||||
isLoading.value = false;
|
||||
// Navigate to success page or home page
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
isLoading.value = false;
|
||||
// Handle error
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -409,17 +409,7 @@ class IdCardVerificationController extends GetxController {
|
|||
if (isIdCardValid.value) {
|
||||
hasConfirmedIdCard.value = true;
|
||||
|
||||
// Log storage data for debugging
|
||||
SharedPreferences.getInstance().then((prefs) {
|
||||
Logger().i('Storage check on confirmation:');
|
||||
Logger().i(
|
||||
'OCR results: ${prefs.getString(_kOcrResultsKey)?.substring(0, 50)}...',
|
||||
);
|
||||
Logger().i(
|
||||
'OCR model: ${prefs.getString(_kOcrModelKey)?.substring(0, 50)}...',
|
||||
);
|
||||
Logger().i('ID card type: ${prefs.getString(_kIdCardTypeKey)}');
|
||||
});
|
||||
clearErrors(); // Clear any previous errors
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -62,16 +62,37 @@ class OfficerInfoController extends GetxController {
|
|||
Rx<DateTime?> selectedValidUntil = Rx<DateTime?>(null);
|
||||
Rx<DateTime?> selectedDateOfBirth = Rx<DateTime?>(null);
|
||||
|
||||
// Dark mode reactive state
|
||||
final Rx<bool> isDarkMode = false.obs;
|
||||
|
||||
final isDark = Get.isDarkMode;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
|
||||
// Initialize isDarkMode with current value
|
||||
_updateThemeMode();
|
||||
|
||||
initRepositories();
|
||||
|
||||
// Fetch units after ensuring repositories are set up
|
||||
getAvailableUnits();
|
||||
}
|
||||
|
||||
// Update theme mode based on current Get.isDarkMode value
|
||||
void _updateThemeMode() {
|
||||
// Check the brightness of the current theme
|
||||
final brightness = Get.theme.brightness;
|
||||
isDarkMode.value = brightness == Brightness.dark;
|
||||
}
|
||||
|
||||
// Method to check dark mode that can be called from UI
|
||||
bool checkIsDarkMode(BuildContext context) {
|
||||
final brightness = Theme.of(context).brightness;
|
||||
return brightness == Brightness.dark;
|
||||
}
|
||||
|
||||
void initRepositories() {
|
||||
// Check if repositories are already registered with GetX
|
||||
unitRepository = Get.find<UnitRepository>();
|
||||
|
@ -172,6 +193,48 @@ class OfficerInfoController extends GetxController {
|
|||
isValid = false;
|
||||
}
|
||||
|
||||
if (rankController.text.isEmpty) {
|
||||
rankError.value = 'Rank is required';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (positionController.text.isEmpty) {
|
||||
positionError.value = 'Position is required';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (phoneController.text.isEmpty) {
|
||||
phoneError.value = 'Phone number is required';
|
||||
isValid = false;
|
||||
} else if (!RegExp(r'^\+?[0-9]{10,15}$').hasMatch(phoneController.text)) {
|
||||
phoneError.value = 'Invalid phone number format';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Date of Birth validation
|
||||
if (dateOfBirthController.text.isEmpty) {
|
||||
dateOfBirthError.value = 'Date of birth is required';
|
||||
isValid = false;
|
||||
} else if (selectedDateOfBirth.value == null) {
|
||||
dateOfBirthError.value = 'Please select a valid birth date';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Valid Until validation
|
||||
if (validUntilController.text.isEmpty) {
|
||||
validUntilError.value = 'Valid until date is required';
|
||||
isValid = false;
|
||||
} else if (selectedValidUntil.value == null) {
|
||||
validUntilError.value = 'Please select a valid date';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (placeOfBirthController.text.isEmpty) {
|
||||
placeOfBirthError.value = 'Place of birth is required';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Unit selection validation - fixed logic
|
||||
if (unitIdController.text.isEmpty) {
|
||||
unitIdError.value = 'Please select a unit';
|
||||
isValid = false;
|
||||
|
@ -181,8 +244,21 @@ class OfficerInfoController extends GetxController {
|
|||
return isValid;
|
||||
}
|
||||
|
||||
void updateOfficer(OfficerModel officer, UserMetadataModel metadata) async {
|
||||
void submitRegistration(
|
||||
OfficerInfoController controller,
|
||||
// FormRegistrationController mainController,
|
||||
GlobalKey<FormState> formKey,
|
||||
) async {
|
||||
try {
|
||||
// First validate the form before showing the loading dialog
|
||||
if (!validate(formKey)) {
|
||||
TLoaders.errorSnackBar(
|
||||
title: 'Validation Error',
|
||||
message: 'Please fix the errors in the form before submitting.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
TCircularFullScreenLoader.openLoadingDialog();
|
||||
|
||||
final isConnected = await NetworkManager.instance.isConnected();
|
||||
|
@ -195,49 +271,57 @@ class OfficerInfoController extends GetxController {
|
|||
return;
|
||||
}
|
||||
|
||||
// Validate the form before proceeding
|
||||
if (!validate(null)) {
|
||||
// No need to validate again, already done above
|
||||
final userId = AuthenticationRepository.instance.authUser!.id;
|
||||
|
||||
final roleId =
|
||||
AuthenticationRepository.instance.authUser!.userMetadata!['role_id'];
|
||||
|
||||
// Check if the user has a valid role
|
||||
if (roleId == null || roleId.isEmpty) {
|
||||
TLoaders.errorSnackBar(
|
||||
title: 'Validation Error',
|
||||
message: 'Please fix the errors in the form before submitting.',
|
||||
title: 'Role Error',
|
||||
message: 'User does not have a valid role assigned.',
|
||||
);
|
||||
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,
|
||||
OfficerModel officer = await OfficerRepository.instance.getOfficerById(
|
||||
userId,
|
||||
);
|
||||
|
||||
Logger().i('Updating officer with data: ${data.toJson()}');
|
||||
// Convert Map<String, dynamic> to UserMetadataModel
|
||||
|
||||
// Create a new OfficerModel instance with the provided data
|
||||
final updateOfficer = officer.copyWith(
|
||||
unitId: unitIdController.text.trim(),
|
||||
nrp: nrpController.text.trim(),
|
||||
name: nameController.text.trim(),
|
||||
rank: rankController.text.trim(),
|
||||
position: positionController.text.trim(),
|
||||
phone: phoneController.text.trim(),
|
||||
placeOfBirth: placeOfBirthController.text.trim(),
|
||||
dateOfBirth: selectedDateOfBirth.value,
|
||||
validUntil: selectedValidUntil.value,
|
||||
);
|
||||
|
||||
// Logger().i('Updating officer with data: ${updateOfficer.toJson()}');
|
||||
|
||||
// final updatedOfficer = await OfficerRepository.instance.updateOfficer(
|
||||
// data,
|
||||
// );
|
||||
final updatedOfficer = await OfficerRepository.instance.updateOfficer(
|
||||
updateOfficer,
|
||||
);
|
||||
|
||||
// if (updatedOfficer == null) {
|
||||
// TLoaders.errorSnackBar(
|
||||
// title: 'Update Failed',
|
||||
// message: 'Failed to update officer information. Please try again.',
|
||||
// );
|
||||
// TCircularFullScreenLoader.stopLoading();
|
||||
// return;
|
||||
// }
|
||||
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);
|
||||
await UserRepository.instance.updateProfileStatus('completed');
|
||||
|
||||
// TLoaders.successSnackBar(
|
||||
// title: 'Update Successful',
|
||||
|
@ -245,18 +329,22 @@ class OfficerInfoController extends GetxController {
|
|||
// );
|
||||
|
||||
// resetForm();
|
||||
// TCircularFullScreenLoader.stopLoading();
|
||||
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(),
|
||||
// ),
|
||||
// );
|
||||
Get.off(
|
||||
() => StateScreen(
|
||||
title: 'Officer Information Created',
|
||||
subtitle: 'Officer information has been successfully created.',
|
||||
primaryButtonTitle: 'Back to signin',
|
||||
image:
|
||||
isDarkMode.value
|
||||
? TImages.womanHuggingEarthDark
|
||||
: TImages.womanHuggingEarth,
|
||||
isSvg: true,
|
||||
showButton: true,
|
||||
onPressed: () => AuthenticationRepository.instance.screenRedirect(),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
logger.e('Error updating officer: $e');
|
||||
TCircularFullScreenLoader.stopLoading();
|
||||
|
|
|
@ -7,7 +7,6 @@ import 'package:sigap/src/features/auth/presentasion/pages/signup/step/identity-
|
|||
import 'package:sigap/src/features/auth/presentasion/pages/signup/step/officer-information/officer_info_step.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/signup/step/personal-information/personal_info_step.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/signup/step/selfie-verification/selfie_verification_step.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
|
||||
import 'package:sigap/src/shared/widgets/indicators/step_indicator/step_indicator.dart';
|
||||
import 'package:sigap/src/utils/constants/colors.dart';
|
||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||
|
@ -67,9 +66,6 @@ class FormRegistrationScreen extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Navigation buttons
|
||||
_buildNavigationButtons(controller),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
@ -108,53 +104,24 @@ class FormRegistrationScreen extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildNavigationButtons(FormRegistrationController controller) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(TSizes.defaultSpace),
|
||||
child: Row(
|
||||
children: [
|
||||
// Back button
|
||||
Obx(
|
||||
() =>
|
||||
controller.currentStep.value > 0
|
||||
? Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: TSizes.sm),
|
||||
child: AuthButton(
|
||||
text: 'Previous',
|
||||
onPressed: controller.previousStep,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
|
||||
// Next/Submit button
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: controller.currentStep.value > 0 ? TSizes.sm : 0.0,
|
||||
),
|
||||
child: Obx(
|
||||
() => AuthButton(
|
||||
text:
|
||||
controller.currentStep.value == controller.totalSteps - 1
|
||||
? 'Submit'
|
||||
: 'Next',
|
||||
onPressed: controller.nextStep,
|
||||
isLoading: controller.isLoading.value,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStepContent(FormRegistrationController controller) {
|
||||
final isOfficer = controller.selectedRole.value?.isOfficer ?? false;
|
||||
|
||||
// Different step content for officer vs viewer
|
||||
if (isOfficer) {
|
||||
// Officer registration flow (3 steps)
|
||||
switch (controller.currentStep.value) {
|
||||
case 0:
|
||||
return const IdCardVerificationStep();
|
||||
case 1:
|
||||
return const SelfieVerificationStep();
|
||||
case 2:
|
||||
return const OfficerInfoStep();
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
} else {
|
||||
// Viewer registration flow (4 steps)
|
||||
switch (controller.currentStep.value) {
|
||||
case 0:
|
||||
return const PersonalInfoStep();
|
||||
|
@ -163,11 +130,10 @@ class FormRegistrationScreen extends StatelessWidget {
|
|||
case 2:
|
||||
return const SelfieVerificationStep();
|
||||
case 3:
|
||||
return isOfficer
|
||||
? const OfficerInfoStep()
|
||||
: const IdentityVerificationStep();
|
||||
return const IdentityVerificationStep();
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,6 @@ import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
|
|||
import 'package:sigap/src/features/auth/presentasion/widgets/auth_divider.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/widgets/password_field.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/widgets/social_button.dart';
|
||||
import 'package:sigap/src/features/onboarding/presentasion/controllers/role_selection_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/image_strings.dart';
|
||||
|
|
|
@ -8,6 +8,7 @@ import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/id-
|
|||
import 'package:sigap/src/shared/widgets/image_upload/image_source_dialog.dart';
|
||||
import 'package:sigap/src/shared/widgets/image_upload/image_uploader.dart';
|
||||
import 'package:sigap/src/shared/widgets/info/tips_container.dart';
|
||||
import 'package:sigap/src/shared/widgets/navigation/step_navigation_buttons.dart';
|
||||
import 'package:sigap/src/shared/widgets/verification/ocr_result_card.dart';
|
||||
import 'package:sigap/src/shared/widgets/verification/validation_message_card.dart';
|
||||
import 'package:sigap/src/utils/constants/colors.dart';
|
||||
|
@ -146,11 +147,50 @@ class IdCardVerificationStep extends StatelessWidget {
|
|||
// Tips Section
|
||||
const SizedBox(height: TSizes.spaceBtwItems),
|
||||
_buildIdCardTips(idCardType),
|
||||
|
||||
// Add space before navigation buttons
|
||||
const SizedBox(height: TSizes.spaceBtwSections),
|
||||
|
||||
// Navigation buttons with loading state
|
||||
Obx(
|
||||
() => StepNavigationButtons(
|
||||
showPrevious: mainController.currentStep.value > 0,
|
||||
isLastStep: false,
|
||||
onPrevious: mainController.previousStep,
|
||||
onNext: () => _handleNextStep(controller, mainController),
|
||||
isLoading:
|
||||
controller.isVerifying.value ||
|
||||
controller.isUploadingIdCard.value,
|
||||
errorMessage: controller.idCardError.value,
|
||||
nextButtonText: 'Continue',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Add a method to handle the next step validation
|
||||
void _handleNextStep(
|
||||
IdCardVerificationController controller,
|
||||
FormRegistrationController mainController,
|
||||
) {
|
||||
// Validate that ID card is uploaded and verified
|
||||
if (controller.idCardImage.value == null) {
|
||||
controller.idCardError.value = 'Please upload your ID card to continue';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!controller.hasConfirmedIdCard.value) {
|
||||
controller.idCardError.value =
|
||||
'Your ID card must be confirmed before going to the next step';
|
||||
return;
|
||||
}
|
||||
|
||||
// If everything is valid, go to next step
|
||||
mainController.nextStep();
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context, String idCardType) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
|
@ -5,6 +5,7 @@ import 'package:sigap/src/features/auth/presentasion/controllers/signup/main/reg
|
|||
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/shared/widgets/form/form_section_header.dart';
|
||||
import 'package:sigap/src/shared/widgets/navigation/step_navigation_buttons.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';
|
||||
|
@ -20,7 +21,9 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
final mainController = Get.find<FormRegistrationController>();
|
||||
mainController.formKey = formKey;
|
||||
|
||||
// Check if KTA data exists and populate fields if available
|
||||
final isSubmitting = false.obs;
|
||||
final submissionError = ''.obs;
|
||||
|
||||
_populateFieldsFromKta(
|
||||
controller,
|
||||
mainController.idCardVerificationController,
|
||||
|
@ -53,7 +56,6 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
controller.nrpError.value = '';
|
||||
},
|
||||
),
|
||||
_buildErrorText(controller.nrpError),
|
||||
|
||||
// Name field
|
||||
CustomTextField(
|
||||
|
@ -74,7 +76,6 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
controller.nameError.value = '';
|
||||
},
|
||||
),
|
||||
_buildErrorText(controller.nameError),
|
||||
|
||||
// Rank field
|
||||
CustomTextField(
|
||||
|
@ -89,10 +90,10 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
controller.rankError.value = '';
|
||||
},
|
||||
),
|
||||
_buildErrorText(controller.rankError),
|
||||
|
||||
// Position field
|
||||
CustomTextField(
|
||||
|
||||
label: 'Position',
|
||||
controller: controller.positionController,
|
||||
validator: (v) => TValidators.validateUserInput('Position', v, 100),
|
||||
|
@ -103,8 +104,8 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
controller.positionController.text = value;
|
||||
controller.positionError.value = '';
|
||||
},
|
||||
|
||||
),
|
||||
_buildErrorText(controller.positionError),
|
||||
|
||||
// Phone field
|
||||
CustomTextField(
|
||||
|
@ -120,7 +121,6 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
controller.phoneError.value = '';
|
||||
},
|
||||
),
|
||||
_buildErrorText(controller.phoneError),
|
||||
|
||||
// Place of Birth field
|
||||
CustomTextField(
|
||||
|
@ -136,7 +136,6 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
controller.placeOfBirthError.value = '';
|
||||
},
|
||||
),
|
||||
_buildErrorText(controller.placeOfBirthError),
|
||||
|
||||
// Date of Birth field
|
||||
_buildDateField(
|
||||
|
@ -145,15 +144,10 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
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(),
|
||||
hintText: 'Select your birth date',
|
||||
dateType: DateFieldType.birthDate,
|
||||
onDateSelected: controller.setDateOfBirth,
|
||||
),
|
||||
_buildErrorText(controller.dateOfBirthError),
|
||||
|
||||
// Valid Until field
|
||||
_buildDateField(
|
||||
|
@ -162,15 +156,10 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
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),
|
||||
hintText: 'Select expiry date',
|
||||
dateType: DateFieldType.validUntil,
|
||||
onDateSelected: controller.setValidUntilDate,
|
||||
),
|
||||
_buildErrorText(controller.validUntilError),
|
||||
|
||||
const SizedBox(height: TSizes.spaceBtwSections),
|
||||
|
||||
|
@ -182,8 +171,9 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
|
||||
const SizedBox(height: TSizes.spaceBtwItems),
|
||||
|
||||
// Unit dropdown
|
||||
// Unit dropdown with error styling
|
||||
_buildUnitDropdown(
|
||||
context,
|
||||
controller,
|
||||
mainController.idCardVerificationController,
|
||||
),
|
||||
|
@ -225,41 +215,66 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: TSizes.spaceBtwSections),
|
||||
|
||||
// Navigation buttons
|
||||
Obx(
|
||||
() => StepNavigationButtons(
|
||||
showPrevious: true,
|
||||
isLastStep: true,
|
||||
onPrevious: mainController.previousStep,
|
||||
onNext: () => controller.submitRegistration(controller, formKey),
|
||||
isLoading: isSubmitting.value,
|
||||
errorMessage: submissionError.value,
|
||||
nextButtonText: 'Submit Registration',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
if (ktaData != null) {
|
||||
controller.populateFromKta(ktaData);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to build error text consistently
|
||||
// error text with better styling
|
||||
Widget _buildErrorText(RxString errorValue) {
|
||||
return Obx(
|
||||
() =>
|
||||
errorValue.value.isNotEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
? Container(
|
||||
margin: const EdgeInsets.only(top: 6, left: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 14, color: TColors.error),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
errorValue.value,
|
||||
style: TextStyle(color: Colors.red[700], fontSize: 12),
|
||||
style: TextStyle(
|
||||
color: TColors.error,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
|
||||
// Build date picker field
|
||||
// date picker with proper date ranges and error styling
|
||||
Widget _buildDateField({
|
||||
required BuildContext context,
|
||||
required OfficerInfoController controller,
|
||||
|
@ -267,36 +282,85 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
required TextEditingController textController,
|
||||
required RxString errorValue,
|
||||
required String hintText,
|
||||
required DateTime initialDate,
|
||||
required DateTime firstDate,
|
||||
required DateTime lastDate,
|
||||
required DateFieldType dateType,
|
||||
required Function(DateTime) onDateSelected,
|
||||
}) {
|
||||
return Obx(() {
|
||||
final hasError = errorValue.value.isNotEmpty;
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
|
||||
// Define date ranges based on type
|
||||
DateTime initialDate;
|
||||
DateTime firstDate;
|
||||
DateTime lastDate;
|
||||
|
||||
switch (dateType) {
|
||||
case DateFieldType.birthDate:
|
||||
// For birth date: past dates only
|
||||
final now = DateTime.now();
|
||||
// Set initialDate to 25 years ago, allow dates from 1940 up to today (no future dates)
|
||||
initialDate = DateTime(
|
||||
now.year - 25,
|
||||
now.month,
|
||||
now.day,
|
||||
); // Default to 25 years ago
|
||||
firstDate = DateTime(1940); // Allow very old dates
|
||||
lastDate = DateTime(
|
||||
now.year,
|
||||
now.month,
|
||||
now.day,
|
||||
); // Today (no future)
|
||||
break;
|
||||
case DateFieldType.validUntil:
|
||||
// For valid until: future dates only
|
||||
final now = DateTime.now();
|
||||
initialDate = DateTime(
|
||||
now.year + 1,
|
||||
now.month,
|
||||
now.day,
|
||||
); // Default to 1 year from now
|
||||
firstDate = now; // Start from today
|
||||
lastDate = DateTime(2100); // Far future
|
||||
break;
|
||||
}
|
||||
|
||||
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 {
|
||||
// Label
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: hasError ? TColors.error : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TSizes.sm),
|
||||
|
||||
// Date field container
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: initialDate,
|
||||
initialDate:
|
||||
textController.text.isNotEmpty
|
||||
? DateTime.tryParse(textController.text) ?? initialDate
|
||||
: initialDate,
|
||||
firstDate: firstDate,
|
||||
lastDate: lastDate,
|
||||
helpText:
|
||||
dateType == DateFieldType.birthDate
|
||||
? 'Select your birth date'
|
||||
: 'Select expiry date',
|
||||
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,
|
||||
data: theme.copyWith(
|
||||
colorScheme: theme.colorScheme.copyWith(
|
||||
primary: isDark ? TColors.accent : TColors.primary,
|
||||
onPrimary: isDark ? TColors.primary : TColors.accent,
|
||||
surface: isDark ? Colors.grey[800] : TColors.accent,
|
||||
onSurface: isDark ? TColors.accent : Colors.black,
|
||||
),
|
||||
),
|
||||
child: child!,
|
||||
|
@ -306,50 +370,114 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
|
||||
if (date != null) {
|
||||
onDateSelected(date);
|
||||
errorValue.value = ''; // Clear error when date is selected
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: TSizes.md,
|
||||
vertical: TSizes.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color:
|
||||
hasError
|
||||
? TColors.error
|
||||
: (isDark ? Colors.grey[600]! : Colors.grey[300]!),
|
||||
width: hasError ? 2 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(TSizes.inputFieldRadius),
|
||||
color: (isDark ? TColors.dark : TColors.lightContainer),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.calendar_today,
|
||||
color:
|
||||
hasError
|
||||
? TColors.error
|
||||
: (isDark ? Colors.grey[400] : Colors.grey[600]),
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
textController.text.isNotEmpty
|
||||
? _formatDisplayDate(textController.text)
|
||||
: hintText,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color:
|
||||
(hasError
|
||||
? TColors.error
|
||||
: theme.textTheme.bodyMedium?.color),
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color:
|
||||
hasError
|
||||
? TColors.error
|
||||
: (isDark ? Colors.grey[400] : Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Error message
|
||||
_buildErrorText(errorValue),
|
||||
|
||||
// Helper text for date range
|
||||
if (!hasError && textController.text.isEmpty)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 6, left: 4),
|
||||
child: Text(
|
||||
dateType == DateFieldType.birthDate
|
||||
? 'Select a date from the past'
|
||||
: 'Select a future date for document expiry',
|
||||
style: TextStyle(
|
||||
color: isDark ? Colors.grey[400] : Colors.grey[600],
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: TSizes.spaceBtwInputFields),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Build unit dropdown selection
|
||||
// unit dropdown with error styling
|
||||
Widget _buildUnitDropdown(
|
||||
BuildContext context,
|
||||
OfficerInfoController controller,
|
||||
IdCardVerificationController idCardController,
|
||||
) {
|
||||
return Obx(() {
|
||||
final hasError = controller.unitIdError.value.isNotEmpty;
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final borderRadius = BorderRadius.circular(TSizes.inputFieldRadius);
|
||||
final fillColor = isDark ? TColors.dark : TColors.accent;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Label - using context directly
|
||||
Builder(
|
||||
builder: (context) {
|
||||
return Text(
|
||||
// Label
|
||||
Text(
|
||||
'Select Unit:',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
);
|
||||
},
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: hasError ? TColors.error : null,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: TSizes.sm),
|
||||
|
||||
// Dropdown using Builder to access current context (and theme)
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// Use custom text field styling for consistency
|
||||
final borderRadius = BorderRadius.circular(TSizes.inputFieldRadius);
|
||||
final fillColor = isDark ? TColors.dark : TColors.lightContainer;
|
||||
|
||||
return GetX<OfficerInfoController>(
|
||||
builder: (controller) {
|
||||
if (controller.isLoadingUnits.value) {
|
||||
return Container(
|
||||
// Loading state
|
||||
if (controller.isLoadingUnits.value)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: TSizes.md,
|
||||
vertical: TSizes.md,
|
||||
|
@ -362,64 +490,91 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(TSizes.sm),
|
||||
child: CircularProgressIndicator(
|
||||
color: theme.primaryColor,
|
||||
child: CircularProgressIndicator(color: theme.primaryColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (controller.availableUnits.isEmpty) {
|
||||
return Container(
|
||||
)
|
||||
// No units available
|
||||
else if (controller.availableUnits.isEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: TSizes.md,
|
||||
vertical: TSizes.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.dividerColor),
|
||||
border: Border.all(
|
||||
color: hasError ? TColors.error : theme.dividerColor,
|
||||
width: hasError ? 2 : 1,
|
||||
),
|
||||
borderRadius: borderRadius,
|
||||
color: fillColor,
|
||||
),
|
||||
child: Text(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_outlined,
|
||||
color: hasError ? TColors.error : Colors.orange[600],
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'No units available',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: isDark ? Colors.grey[400] : Colors.grey[600],
|
||||
color: hasError ? TColors.error : Colors.orange[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
// Units available
|
||||
else
|
||||
_buildUnitDropdownContent(
|
||||
controller,
|
||||
idCardController,
|
||||
hasError,
|
||||
theme,
|
||||
isDark,
|
||||
borderRadius,
|
||||
fillColor,
|
||||
),
|
||||
|
||||
// Error message
|
||||
_buildErrorText(controller.unitIdError),
|
||||
|
||||
const SizedBox(height: TSizes.spaceBtwInputFields),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Get the selected unit (if any)
|
||||
Widget _buildUnitDropdownContent(
|
||||
OfficerInfoController controller,
|
||||
IdCardVerificationController idCardController,
|
||||
bool hasError,
|
||||
ThemeData theme,
|
||||
bool isDark,
|
||||
BorderRadius borderRadius,
|
||||
Color fillColor,
|
||||
) {
|
||||
return GetX<OfficerInfoController>(
|
||||
builder: (controller) {
|
||||
final selectedUnit = controller.availableUnits.firstWhereOrNull(
|
||||
(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
|
||||
// Auto-select matching unit from KTA data
|
||||
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 ktaUnit = idCardController.ktaModel.value?.policeUnit ?? '';
|
||||
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
|
||||
unit.name.toLowerCase().contains(ktaUnit.toLowerCase()) ||
|
||||
ktaUnit.toLowerCase().contains(unit.name.toLowerCase()),
|
||||
);
|
||||
|
||||
if (matchingUnit != null) {
|
||||
// Use Future.microtask to avoid setState during build
|
||||
Future.microtask(
|
||||
() => controller.onUnitSelected(matchingUnit),
|
||||
);
|
||||
Future.microtask(() => controller.onUnitSelected(matchingUnit));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -428,8 +583,11 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
// Dropdown Selection Button
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
// Toggle dropdown visibility
|
||||
controller.isUnitDropdownOpen.toggle();
|
||||
if (hasError) {
|
||||
controller.unitIdError.value =
|
||||
''; // Clear error on interaction
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
|
@ -439,17 +597,34 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color:
|
||||
controller.isUnitDropdownOpen.value
|
||||
hasError
|
||||
? TColors.error
|
||||
: (controller.isUnitDropdownOpen.value
|
||||
? theme.primaryColor
|
||||
: theme.dividerColor,
|
||||
: theme.dividerColor),
|
||||
width:
|
||||
controller.isUnitDropdownOpen.value ? 1.5 : 1,
|
||||
hasError || controller.isUnitDropdownOpen.value ? 2 : 1,
|
||||
),
|
||||
borderRadius: borderRadius,
|
||||
color: fillColor,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
selectedUnit != null
|
||||
? Icons.shield
|
||||
: Icons.shield_outlined,
|
||||
color:
|
||||
hasError
|
||||
? TColors.error
|
||||
: (selectedUnit != null
|
||||
? theme.primaryColor
|
||||
: (isDark
|
||||
? Colors.grey[400]
|
||||
: Colors.grey[600])),
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
selectedUnit != null
|
||||
|
@ -457,11 +632,13 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
: 'Select Unit',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color:
|
||||
selectedUnit != null
|
||||
hasError
|
||||
? TColors.error
|
||||
: (selectedUnit != null
|
||||
? theme.textTheme.bodyMedium?.color
|
||||
: (isDark
|
||||
? Colors.grey[400]
|
||||
: Colors.grey[600]),
|
||||
: Colors.grey[600])),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -470,11 +647,13 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
? Icons.keyboard_arrow_up
|
||||
: Icons.keyboard_arrow_down,
|
||||
color:
|
||||
controller.isUnitDropdownOpen.value
|
||||
hasError
|
||||
? TColors.error
|
||||
: (controller.isUnitDropdownOpen.value
|
||||
? theme.primaryColor
|
||||
: (isDark
|
||||
? Colors.grey[400]
|
||||
: Colors.grey[600]),
|
||||
: Colors.grey[600])),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@ -490,14 +669,14 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
borderRadius: borderRadius,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(
|
||||
isDark ? 0.3 : 0.1,
|
||||
),
|
||||
color: Colors.black.withOpacity(isDark ? 0.3 : 0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
border: Border.all(color: theme.dividerColor),
|
||||
border: Border.all(
|
||||
color: hasError ? TColors.error : theme.dividerColor,
|
||||
),
|
||||
),
|
||||
constraints: const BoxConstraints(maxHeight: 250),
|
||||
child: ClipRRect(
|
||||
|
@ -509,13 +688,14 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
itemBuilder: (context, index) {
|
||||
final unit = controller.availableUnits[index];
|
||||
final isSelected =
|
||||
unit.codeUnit ==
|
||||
controller.unitIdController.text;
|
||||
unit.codeUnit == controller.unitIdController.text;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
controller.onUnitSelected(unit);
|
||||
controller.isUnitDropdownOpen.value = false;
|
||||
controller.unitIdError.value =
|
||||
''; // Clear error on selection
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
|
@ -530,15 +710,12 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
)
|
||||
: Colors.transparent,
|
||||
border:
|
||||
index <
|
||||
controller
|
||||
.availableUnits
|
||||
.length -
|
||||
1
|
||||
index < controller.availableUnits.length - 1
|
||||
? Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.dividerColor
|
||||
.withOpacity(0.5),
|
||||
color: theme.dividerColor.withOpacity(
|
||||
0.5,
|
||||
),
|
||||
width: 0.5,
|
||||
),
|
||||
)
|
||||
|
@ -546,7 +723,6 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Unit Icon
|
||||
Icon(
|
||||
isSelected
|
||||
? Icons.shield
|
||||
|
@ -560,20 +736,14 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Unit Name
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${unit.name} (${unit.type.name})',
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color:
|
||||
isSelected
|
||||
? theme.primaryColor
|
||||
: theme
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.color,
|
||||
: theme.textTheme.bodyMedium?.color,
|
||||
fontWeight:
|
||||
isSelected
|
||||
? FontWeight.bold
|
||||
|
@ -581,8 +751,6 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Checkmark for selected item
|
||||
if (isSelected)
|
||||
Icon(
|
||||
Icons.check,
|
||||
|
@ -599,26 +767,18 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
),
|
||||
|
||||
// Selected unit display
|
||||
if (selectedUnit != null &&
|
||||
!controller.isUnitDropdownOpen.value)
|
||||
if (selectedUnit != null && !controller.isUnitDropdownOpen.value)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(
|
||||
top: TSizes.spaceBtwInputFields,
|
||||
),
|
||||
margin: const EdgeInsets.only(top: TSizes.spaceBtwInputFields),
|
||||
padding: const EdgeInsets.all(TSizes.md),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.primaryColor.withOpacity(
|
||||
isDark ? 0.2 : 0.1,
|
||||
),
|
||||
color: theme.primaryColor.withOpacity(isDark ? 0.2 : 0.1),
|
||||
borderRadius: borderRadius,
|
||||
border: Border.all(color: theme.primaryColor),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.shield_outlined,
|
||||
color: theme.primaryColor,
|
||||
),
|
||||
Icon(Icons.shield_outlined, color: theme.primaryColor),
|
||||
const SizedBox(width: TSizes.sm),
|
||||
Expanded(
|
||||
child: Column(
|
||||
|
@ -628,9 +788,7 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
'Selected Unit',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color:
|
||||
isDark
|
||||
? Colors.grey[400]
|
||||
: Colors.grey[600],
|
||||
isDark ? Colors.grey[400] : Colors.grey[600],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
|
@ -650,16 +808,36 @@ class OfficerInfoStep extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Error message
|
||||
_buildErrorText(controller.unitIdError),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
// Helper method to format date for display
|
||||
String _formatDisplayDate(String dateString) {
|
||||
try {
|
||||
final date = DateTime.parse(dateString);
|
||||
final months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
return '${date.day} ${months[date.month - 1]} ${date.year}';
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enum for date field types
|
||||
enum DateFieldType { birthDate, validUntil }
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -6,6 +6,7 @@ import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/sel
|
|||
import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/selfie-verification/selfie_verification_controller.dart';
|
||||
import 'package:sigap/src/shared/widgets/image_upload/image_uploader.dart';
|
||||
import 'package:sigap/src/shared/widgets/info/tips_container.dart';
|
||||
import 'package:sigap/src/shared/widgets/navigation/step_navigation_buttons.dart';
|
||||
import 'package:sigap/src/shared/widgets/verification/validation_message_card.dart';
|
||||
import 'package:sigap/src/utils/constants/colors.dart';
|
||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||
|
@ -67,11 +68,53 @@ class SelfieVerificationStep extends StatelessWidget {
|
|||
|
||||
// Tips container
|
||||
_buildSelfieTips(),
|
||||
|
||||
// Add space before navigation buttons
|
||||
const SizedBox(height: TSizes.spaceBtwSections),
|
||||
|
||||
// Navigation buttons with loading state
|
||||
Obx(
|
||||
() => StepNavigationButtons(
|
||||
showPrevious: true,
|
||||
isLastStep: false,
|
||||
onPrevious: mainController.previousStep,
|
||||
onNext: () => _handleNextStep(controller, mainController),
|
||||
isLoading:
|
||||
controller.isVerifyingFace.value ||
|
||||
controller.isComparingWithIDCard.value ||
|
||||
controller.isPerformingLivenessCheck.value,
|
||||
errorMessage: controller.selfieError.value,
|
||||
nextButtonText: 'Continue',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Add a method to handle the next step validation
|
||||
void _handleNextStep(
|
||||
SelfieVerificationController controller,
|
||||
FormRegistrationController mainController,
|
||||
) {
|
||||
// Validate that selfie is taken and verified
|
||||
if (controller.selfieImage.value == null) {
|
||||
controller.selfieError.value =
|
||||
'Please complete selfie verification to continue';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!controller.isMatchWithIDCard.value &&
|
||||
!controller.autoVerifyForDev.value) {
|
||||
controller.selfieError.value =
|
||||
'Your selfie verification must be completed successfully';
|
||||
return;
|
||||
}
|
||||
|
||||
// If everything is valid, go to next step
|
||||
mainController.nextStep();
|
||||
}
|
||||
|
||||
Widget _buildDevelopmentModeIndicator(FacialVerificationService service) {
|
||||
if (!service.skipFaceVerification) return const SizedBox.shrink();
|
||||
|
||||
|
|
|
@ -26,7 +26,7 @@ class AuthButton extends StatelessWidget {
|
|||
onPressed: isLoading ? null : onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: backgroundColor ?? Theme.of(context).primaryColor,
|
||||
foregroundColor: textColor ?? Colors.white,
|
||||
foregroundColor: textColor ?? Theme.of(context).colorScheme.onPrimary,
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(TSizes.buttonRadius),
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import 'package:sigap/src/features/personalization/data/models/models/roles_model.dart';
|
||||
|
||||
class OfficerModel {
|
||||
final String id;
|
||||
final String unitId;
|
||||
final String roleId;
|
||||
final String? id;
|
||||
final String? unitId;
|
||||
final String? roleId;
|
||||
final String? patrolUnitId;
|
||||
final String nrp;
|
||||
final String name;
|
||||
final dynamic nrp; // Changed to dynamic to handle both string and int
|
||||
final String? name;
|
||||
final String? rank;
|
||||
final String? position;
|
||||
final String? phone;
|
||||
|
@ -19,14 +19,20 @@ class OfficerModel {
|
|||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
final RoleModel? role;
|
||||
// New fields from the JSON response
|
||||
final String? bannedReason;
|
||||
final DateTime? bannedUntil;
|
||||
final bool isBanned;
|
||||
final int panicStrike;
|
||||
final int spoofingAttempts;
|
||||
|
||||
OfficerModel({
|
||||
required this.id,
|
||||
required this.unitId,
|
||||
required this.roleId,
|
||||
this.id,
|
||||
this.unitId,
|
||||
this.roleId,
|
||||
this.patrolUnitId,
|
||||
required this.nrp,
|
||||
required this.name,
|
||||
this.nrp,
|
||||
this.name,
|
||||
this.rank,
|
||||
this.position,
|
||||
this.phone,
|
||||
|
@ -39,23 +45,28 @@ class OfficerModel {
|
|||
this.createdAt,
|
||||
this.updatedAt,
|
||||
this.role,
|
||||
this.bannedReason,
|
||||
this.bannedUntil,
|
||||
this.isBanned = false,
|
||||
this.panicStrike = 0,
|
||||
this.spoofingAttempts = 0,
|
||||
});
|
||||
|
||||
// Create an OfficerModel instance from a JSON object
|
||||
factory OfficerModel.fromJson(Map<String, dynamic> json) {
|
||||
return OfficerModel(
|
||||
id: json['id'] as String,
|
||||
unitId: json['unit_id'] as String,
|
||||
roleId: json['role_id'] as String,
|
||||
patrolUnitId: json['patrol_unit_id'] as String?,
|
||||
nrp: json['nrp'] as String,
|
||||
name: json['name'] as String,
|
||||
rank: json['rank'] as String?,
|
||||
position: json['position'] as String?,
|
||||
phone: json['phone'] as String?,
|
||||
email: json['email'] as String?,
|
||||
avatar: json['avatar'] as String?,
|
||||
placeOfBirth: json['birth_place'] as String?,
|
||||
id: json['id'],
|
||||
unitId: json['unit_id'],
|
||||
roleId: json['role_id'],
|
||||
patrolUnitId: json['patrol_unit_id'],
|
||||
nrp: json['nrp'], // Accept as dynamic
|
||||
name: json['name'],
|
||||
rank: json['rank'],
|
||||
position: json['position'],
|
||||
phone: json['phone'],
|
||||
email: json['email'],
|
||||
avatar: json['avatar'],
|
||||
placeOfBirth: json['place_of_birth'] ?? json['birth_place'],
|
||||
dateOfBirth:
|
||||
json['date_of_birth'] != null
|
||||
? DateTime.parse(json['date_of_birth'] as String)
|
||||
|
@ -64,7 +75,7 @@ class OfficerModel {
|
|||
json['valid_until'] != null
|
||||
? DateTime.parse(json['valid_until'] as String)
|
||||
: null,
|
||||
qrCode: json['qr_code'] as String?,
|
||||
qrCode: json['qr_code'],
|
||||
createdAt:
|
||||
json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'] as String)
|
||||
|
@ -73,10 +84,15 @@ class OfficerModel {
|
|||
json['updated_at'] != null
|
||||
? DateTime.parse(json['updated_at'] as String)
|
||||
: null,
|
||||
role:
|
||||
json['roles'] != null
|
||||
? RoleModel.fromJson(json['roles'] as Map<String, dynamic>)
|
||||
// New fields
|
||||
bannedReason: json['banned_reason'],
|
||||
bannedUntil:
|
||||
json['banned_until'] != null
|
||||
? DateTime.parse(json['banned_until'] as String)
|
||||
: null,
|
||||
isBanned: json['is_banned'] ?? false,
|
||||
panicStrike: json['panic_strike'] ?? 0,
|
||||
spoofingAttempts: json['spoofing_attempts'] ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -94,23 +110,60 @@ class OfficerModel {
|
|||
'phone': phone,
|
||||
'email': email,
|
||||
'avatar': avatar,
|
||||
'birth_place': placeOfBirth,
|
||||
'place_of_birth': placeOfBirth,
|
||||
'date_of_birth': dateOfBirth?.toIso8601String(),
|
||||
'valid_until': validUntil?.toIso8601String(),
|
||||
'qr_code': qrCode,
|
||||
'created_at': createdAt?.toIso8601String(),
|
||||
'updated_at': updatedAt?.toIso8601String(),
|
||||
if (role != null) 'roles': role!.toJson(),
|
||||
'banned_reason': bannedReason,
|
||||
'banned_until': bannedUntil?.toIso8601String(),
|
||||
'is_banned': isBanned,
|
||||
'panic_strike': panicStrike,
|
||||
'spoofing_attempts': spoofingAttempts,
|
||||
};
|
||||
}
|
||||
|
||||
// Create to json non null fields
|
||||
Map<String, dynamic> toJsonNonNull() {
|
||||
final json = <String, dynamic>{};
|
||||
|
||||
if (id != null) json['id'] = id;
|
||||
if (unitId != null) json['unit_id'] = unitId;
|
||||
if (roleId != null) json['role_id'] = roleId;
|
||||
if (patrolUnitId != null) json['patrol_unit_id'] = patrolUnitId;
|
||||
if (nrp != null) json['nrp'] = nrp;
|
||||
if (name != null) json['name'] = name;
|
||||
if (rank != null) json['rank'] = rank;
|
||||
if (position != null) json['position'] = position;
|
||||
if (phone != null) json['phone'] = phone;
|
||||
if (email != null) json['email'] = email;
|
||||
if (avatar != null) json['avatar'] = avatar;
|
||||
if (placeOfBirth != null) json['place_of_birth'] = placeOfBirth;
|
||||
if (dateOfBirth != null)
|
||||
json['date_of_birth'] = dateOfBirth!.toIso8601String();
|
||||
if (validUntil != null) json['valid_until'] = validUntil!.toIso8601String();
|
||||
if (qrCode != null) json['qr_code'] = qrCode;
|
||||
if (createdAt != null) json['created_at'] = createdAt!.toIso8601String();
|
||||
if (updatedAt != null) json['updated_at'] = updatedAt!.toIso8601String();
|
||||
// New fields
|
||||
if (bannedReason != null) json['banned_reason'] = bannedReason;
|
||||
if (bannedUntil != null)
|
||||
json['banned_until'] = bannedUntil!.toIso8601String();
|
||||
json['is_banned'] = isBanned;
|
||||
json['panic_strike'] = panicStrike;
|
||||
json['spoofing_attempts'] = spoofingAttempts;
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
// Create a copy of the OfficerModel with updated fields
|
||||
OfficerModel copyWith({
|
||||
String? id,
|
||||
String? unitId,
|
||||
String? roleId,
|
||||
String? patrolUnitId,
|
||||
String? nrp,
|
||||
dynamic nrp, // Changed to dynamic
|
||||
String? name,
|
||||
String? rank,
|
||||
String? position,
|
||||
|
@ -124,6 +177,11 @@ class OfficerModel {
|
|||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
RoleModel? role,
|
||||
String? bannedReason,
|
||||
DateTime? bannedUntil,
|
||||
bool? isBanned,
|
||||
int? panicStrike,
|
||||
int? spoofingAttempts,
|
||||
}) {
|
||||
return OfficerModel(
|
||||
id: id ?? this.id,
|
||||
|
@ -144,6 +202,40 @@ class OfficerModel {
|
|||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
role: role ?? this.role,
|
||||
bannedReason: bannedReason ?? this.bannedReason,
|
||||
bannedUntil: bannedUntil ?? this.bannedUntil,
|
||||
isBanned: isBanned ?? this.isBanned,
|
||||
panicStrike: panicStrike ?? this.panicStrike,
|
||||
spoofingAttempts: spoofingAttempts ?? this.spoofingAttempts,
|
||||
);
|
||||
}
|
||||
|
||||
// Create an empty OfficerModel
|
||||
factory OfficerModel.empty() {
|
||||
return OfficerModel(
|
||||
id: '',
|
||||
unitId: '',
|
||||
roleId: '',
|
||||
patrolUnitId: null,
|
||||
nrp: null,
|
||||
name: '',
|
||||
rank: null,
|
||||
position: null,
|
||||
phone: null,
|
||||
email: null,
|
||||
avatar: null,
|
||||
placeOfBirth: null,
|
||||
dateOfBirth: null,
|
||||
validUntil: null,
|
||||
qrCode: null,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
role: null,
|
||||
bannedReason: null,
|
||||
bannedUntil: null,
|
||||
isBanned: false,
|
||||
panicStrike: 0,
|
||||
spoofingAttempts: 0,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -178,8 +270,6 @@ class OfficerModel {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'OfficerModel(id: $id, name: $name, nrp: $nrp)';
|
||||
|
|
|
@ -51,6 +51,18 @@ class RoleSelectionController extends GetxController {
|
|||
role.name.toLowerCase() == 'officer',
|
||||
)
|
||||
.toList();
|
||||
|
||||
// Set default role to Viewer
|
||||
if (roles.isNotEmpty) {
|
||||
// Find the viewer role
|
||||
final viewerRole = roles.firstWhere(
|
||||
(role) => role.name.toLowerCase() == 'viewer',
|
||||
orElse: () => roles[0], // Fallback to first role if viewer not found
|
||||
);
|
||||
|
||||
// Select the viewer role by default
|
||||
selectRole(viewerRole);
|
||||
}
|
||||
} catch (e) {
|
||||
TLoaders.errorSnackBar(
|
||||
title: 'Error',
|
||||
|
|
|
@ -138,7 +138,7 @@ class RoleSelectionScreen extends StatelessWidget {
|
|||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SvgPicture.asset(
|
||||
TImages.homeOffice,
|
||||
isDark ? TImages.communicationDark : TImages.communication,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
|
|
|
@ -175,7 +175,12 @@ class UserMetadataModel {
|
|||
);
|
||||
}
|
||||
|
||||
/// Create copy with updated fields
|
||||
/// Create a copy of this model with modified attributes
|
||||
///
|
||||
/// This method allows for creating a new instance with selectively updated fields.
|
||||
/// For fields not explicitly provided, the original values are retained.
|
||||
///
|
||||
/// Setting 'isOfficer' will keep consistency with officer/viewer data.
|
||||
UserMetadataModel copyWith({
|
||||
bool? isOfficer,
|
||||
String? userId,
|
||||
|
@ -186,22 +191,49 @@ class UserMetadataModel {
|
|||
UserModel? viewerData,
|
||||
Map<String, dynamic>? additionalData,
|
||||
}) {
|
||||
final newIsOfficer = isOfficer ?? this.isOfficer;
|
||||
|
||||
return UserMetadataModel(
|
||||
isOfficer: isOfficer ?? this.isOfficer,
|
||||
isOfficer: newIsOfficer,
|
||||
userId: userId ?? this.userId,
|
||||
roleId: roleId ?? this.roleId,
|
||||
profileStatus: profileStatus ?? this.profileStatus,
|
||||
email: email ?? this.email,
|
||||
officerData: officerData ?? this.officerData,
|
||||
viewerData: viewerData ?? this.viewerData,
|
||||
// Only include officer data if the model is for an officer
|
||||
officerData: newIsOfficer ? (officerData ?? this.officerData) : null,
|
||||
// Only include viewer data if the model is not for an officer
|
||||
viewerData: !newIsOfficer ? (viewerData ?? this.viewerData) : null,
|
||||
additionalData: additionalData ?? this.additionalData,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a new UserMetadataModel with profile status set to 'completed'
|
||||
UserMetadataModel markAsCompleted() {
|
||||
return copyWith(profileStatus: 'completed');
|
||||
}
|
||||
|
||||
/// Create a new UserMetadataModel with updated officer data
|
||||
UserMetadataModel withUpdatedOfficerData(OfficerModel officer) {
|
||||
return copyWith(
|
||||
isOfficer: true,
|
||||
officerData: officer,
|
||||
profileStatus: 'completed',
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a new UserMetadataModel with updated viewer data
|
||||
UserMetadataModel withUpdatedViewerData(UserModel viewer) {
|
||||
return copyWith(
|
||||
isOfficer: false,
|
||||
viewerData: viewer,
|
||||
profileStatus: 'completed',
|
||||
);
|
||||
}
|
||||
|
||||
// MARK: - Computed properties (getters)
|
||||
|
||||
/// Primary identifier (NRP for officers, NIK for users)
|
||||
String? get identifier => isOfficer ? officerData?.nrp : nik;
|
||||
String? get identifier => isOfficer ? officerData?.nrp?.toString() : nik;
|
||||
|
||||
/// User's NIK (delegated to viewerData if available)
|
||||
String? get nik => viewerData?.profile?.nik;
|
||||
|
@ -211,7 +243,7 @@ class UserMetadataModel {
|
|||
|
||||
/// User's name (delegated to appropriate model or fallback to email)
|
||||
String? get name {
|
||||
if (isOfficer && officerData?.name.isNotEmpty == true) {
|
||||
if (isOfficer && officerData?.name?.isNotEmpty == true) {
|
||||
return officerData!.name;
|
||||
}
|
||||
if (!isOfficer && viewerData?.profile?.fullName?.isNotEmpty == true) {
|
||||
|
@ -238,10 +270,10 @@ class UserMetadataModel {
|
|||
final errors = <String>[];
|
||||
|
||||
if (isOfficer) {
|
||||
if (officerData?.nrp.isEmpty != false) {
|
||||
if (officerData?.nrp == null) {
|
||||
errors.add('NRP is required for officers');
|
||||
}
|
||||
if (officerData?.unitId.isEmpty != false) {
|
||||
if (officerData?.unitId?.isEmpty != false) {
|
||||
errors.add('Unit ID is required for officers');
|
||||
}
|
||||
} else {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart';
|
||||
import 'package:sigap/src/features/daily-ops/data/models/models/officers_model.dart';
|
||||
import 'package:sigap/src/utils/exceptions/exceptions.dart';
|
||||
|
@ -64,6 +65,8 @@ class OfficerRepository extends GetxController {
|
|||
return null;
|
||||
}
|
||||
|
||||
Logger().i('Officer updated successfully: $updatedOfficer');
|
||||
|
||||
// updatedOfficer is a List, so we take the first item and convert it
|
||||
return OfficerModel.fromJson(updatedOfficer);
|
||||
} on PostgrestException catch (error) {
|
||||
|
@ -91,6 +94,8 @@ class OfficerRepository extends GetxController {
|
|||
.eq('id', officerId)
|
||||
.single();
|
||||
|
||||
Logger().i('Fetched officer data: $officerData');
|
||||
|
||||
return OfficerModel.fromJson(officerData);
|
||||
} on PostgrestException catch (error) {
|
||||
throw TExceptions.fromCode(error.code!);
|
||||
|
|
|
@ -116,8 +116,13 @@ class UserRepository extends GetxController {
|
|||
if (!isAuthenticated) {
|
||||
throw 'User not authenticated';
|
||||
}
|
||||
Logger().i('Updating user metadata: $metadata');
|
||||
|
||||
await _supabase.auth.updateUser(UserAttributes(data: metadata));
|
||||
final updatedMetadata = await _supabase.auth.updateUser(
|
||||
UserAttributes(data: metadata),
|
||||
);
|
||||
|
||||
Logger().i('User metadata updated successfully: $updatedMetadata');
|
||||
} on AuthException catch (e) {
|
||||
_logger.e('AuthException in updateUserMetadata: ${e.message}');
|
||||
throw TExceptions(e.message);
|
||||
|
@ -127,6 +132,27 @@ class UserRepository extends GetxController {
|
|||
}
|
||||
}
|
||||
|
||||
// update profile status in user metadata
|
||||
Future<void> updateProfileStatus(String status) async {
|
||||
try {
|
||||
if (!isAuthenticated) {
|
||||
throw 'User not authenticated';
|
||||
}
|
||||
|
||||
final metadata = {
|
||||
'profile_status': status,
|
||||
};
|
||||
|
||||
await updateUserMetadata(metadata);
|
||||
} on AuthException catch (e) {
|
||||
_logger.e('AuthException in updateProfileStatus: ${e.message}');
|
||||
throw TExceptions(e.message);
|
||||
} catch (e) {
|
||||
_logger.e('Exception in updateProfileStatus: $e');
|
||||
throw 'Failed to update profile status: ${e.toString()}';
|
||||
}
|
||||
}
|
||||
|
||||
// Update user email
|
||||
Future<void> updateUserEmail(String newEmail) async {
|
||||
try {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:io';
|
||||
import 'dart:ui'; // Add this import for ImageFilter
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
@ -379,28 +380,51 @@ class ImageUploader extends StatelessWidget {
|
|||
}
|
||||
|
||||
Widget _defaultErrorOverlay() {
|
||||
return Container(
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd - 2),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
|
||||
child: Container(
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd - 2),
|
||||
color: TColors.error.withOpacity(0.2),
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
TColors.error.withOpacity(0.4),
|
||||
Colors.black.withOpacity(0.7),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: TColors.error.withOpacity(0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.error_outline,
|
||||
color: TColors.error,
|
||||
color: Colors.white,
|
||||
size: TSizes.iconLg,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TSizes.sm),
|
||||
Text(
|
||||
'Invalid Image',
|
||||
style: TextStyle(
|
||||
color: TColors.error,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TSizes.xs),
|
||||
|
@ -410,7 +434,7 @@ class ImageUploader extends StatelessWidget {
|
|||
errorMessage ?? 'Please try another image',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: TColors.error,
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
fontSize: TSizes.fontSizeSm,
|
||||
),
|
||||
),
|
||||
|
@ -418,6 +442,9 @@ class ImageUploader extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -447,4 +474,5 @@ class ImageUploader extends StatelessWidget {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,130 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:sigap/src/utils/constants/colors.dart';
|
||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||
|
||||
class StepNavigationButtons extends StatelessWidget {
|
||||
final bool showPrevious;
|
||||
final bool isLastStep;
|
||||
final VoidCallback onPrevious;
|
||||
final VoidCallback onNext;
|
||||
final bool isLoading;
|
||||
final String? errorMessage;
|
||||
final String nextButtonText;
|
||||
final String previousButtonText;
|
||||
final bool
|
||||
useDefaultNavigation; // New parameter for custom navigation control
|
||||
|
||||
const StepNavigationButtons({
|
||||
super.key,
|
||||
this.showPrevious = true,
|
||||
this.isLastStep = false,
|
||||
required this.onPrevious,
|
||||
required this.onNext,
|
||||
this.isLoading = false,
|
||||
this.errorMessage,
|
||||
this.nextButtonText = 'Next',
|
||||
this.previousButtonText = 'Previous',
|
||||
this.useDefaultNavigation =
|
||||
true, // Default to true for backward compatibility
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// Error message if any
|
||||
if (errorMessage != null && errorMessage!.isNotEmpty)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: TSizes.sm),
|
||||
padding: const EdgeInsets.all(TSizes.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: TColors.error.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(TSizes.borderRadiusSm),
|
||||
border: Border.all(color: TColors.error),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: TColors.error, size: 20),
|
||||
const SizedBox(width: TSizes.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
errorMessage!,
|
||||
style: TextStyle(color: TColors.error, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Navigation buttons
|
||||
Row(
|
||||
children: [
|
||||
// Back button
|
||||
if (showPrevious)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: TSizes.sm),
|
||||
child: ElevatedButton(
|
||||
// Use onPrevious only if not loading and either useDefaultNavigation is true or we're handling custom navigation
|
||||
onPressed: isLoading ? null : onPrevious,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.grey[300],
|
||||
foregroundColor: Colors.black87,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
disabledBackgroundColor: Colors.grey[200],
|
||||
disabledForegroundColor: Colors.grey,
|
||||
),
|
||||
child: Text(previousButtonText),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Next/Submit button
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
// For next button, respect the useDefaultNavigation flag
|
||||
onPressed:
|
||||
isLoading
|
||||
? null
|
||||
: () {
|
||||
// If using default navigation or validation logic, just call the provided callback
|
||||
if (useDefaultNavigation) {
|
||||
onNext();
|
||||
} else {
|
||||
// For custom navigation, the caller is responsible for form validation
|
||||
// and navigation logic in the onNext callback
|
||||
onNext();
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: TColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
disabledBackgroundColor: TColors.primary.withOpacity(0.3),
|
||||
),
|
||||
child:
|
||||
isLoading
|
||||
? const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
SizedBox(width: TSizes.xs),
|
||||
],
|
||||
)
|
||||
: Text(isLastStep ? 'Submit' : nextButtonText),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
import 'package:sigap/src/shared/styles/spacing_styles.dart';
|
||||
import 'package:sigap/src/utils/constants/colors.dart';
|
||||
|
@ -20,6 +21,7 @@ class StateScreen extends StatelessWidget {
|
|||
this.primaryButtonTitle = 'Continue',
|
||||
this.onSecondaryPressed,
|
||||
this.isLottie = false,
|
||||
this.isSvg = false,
|
||||
});
|
||||
|
||||
final String? image;
|
||||
|
@ -28,6 +30,7 @@ class StateScreen extends StatelessWidget {
|
|||
final String primaryButtonTitle;
|
||||
final String secondaryTitle;
|
||||
final bool? isLottie;
|
||||
final bool isSvg;
|
||||
final VoidCallback? onPressed;
|
||||
final VoidCallback? onSecondaryPressed;
|
||||
final bool showButton;
|
||||
|
@ -50,7 +53,7 @@ class StateScreen extends StatelessWidget {
|
|||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Image, Icon, or Lottie
|
||||
// Image, Icon, Lottie, or SVG
|
||||
if (icon != null)
|
||||
Icon(
|
||||
icon,
|
||||
|
@ -62,6 +65,15 @@ class StateScreen extends StatelessWidget {
|
|||
image!,
|
||||
width: THelperFunctions.screenWidth() * 0.8,
|
||||
)
|
||||
else if (isSvg && image != null)
|
||||
SvgPicture.asset(
|
||||
image!,
|
||||
width: THelperFunctions.screenWidth() * 0.8,
|
||||
colorFilter: const ColorFilter.mode(
|
||||
TColors.primary,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
)
|
||||
else if (image != null)
|
||||
Image(
|
||||
image: AssetImage(image!),
|
||||
|
|
|
@ -20,6 +20,7 @@ class CustomTextField extends StatelessWidget {
|
|||
final void Function(String)? onChanged;
|
||||
final Color? accentColor;
|
||||
final Color? fillColor;
|
||||
final InputDecoration? decoration; // New parameter for custom decoration
|
||||
|
||||
const CustomTextField({
|
||||
super.key,
|
||||
|
@ -40,8 +41,8 @@ class CustomTextField extends StatelessWidget {
|
|||
this.onChanged,
|
||||
this.accentColor,
|
||||
this.fillColor,
|
||||
this.decoration, // Add to constructor
|
||||
}) : assert(
|
||||
// Fix the assertion to avoid duplicate conditions
|
||||
controller == null || initialValue == null,
|
||||
'Either provide a controller or an initialValue, not both',
|
||||
);
|
||||
|
@ -59,8 +60,10 @@ class CustomTextField extends StatelessWidget {
|
|||
? (isDark ? Colors.grey[800]! : Colors.grey[200]!)
|
||||
: fillColor ?? (isDark ? TColors.dark : TColors.lightContainer);
|
||||
|
||||
// Get the common input decoration for both cases
|
||||
final inputDecoration = _getInputDecoration(
|
||||
// Get the input decoration - either custom or default
|
||||
final inputDecoration =
|
||||
decoration ??
|
||||
_getInputDecoration(
|
||||
context,
|
||||
effectiveAccentColor,
|
||||
isDark,
|
||||
|
|
|
@ -2,5 +2,6 @@ class TNum {
|
|||
// Auth Number
|
||||
static const int oneTimePassword = 6;
|
||||
static const int totalStepViewer = 4;
|
||||
static const int totalStepOfficer = 4;
|
||||
static const int totalStepOfficer =
|
||||
3; // Reduced from 4 to 3 steps for officers
|
||||
}
|
||||
|
|
|
@ -326,12 +326,12 @@ model patrol_units {
|
|||
location_id String @db.Uuid
|
||||
name String @db.VarChar(100)
|
||||
type String @db.VarChar(50)
|
||||
category patrol_unit_category? @default(group)
|
||||
member_count Int? @default(0)
|
||||
status String @db.VarChar(50)
|
||||
radius Float
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
id String @id @unique @db.VarChar(100)
|
||||
category patrol_unit_category? @default(group)
|
||||
member_count Int? @default(0)
|
||||
members officers[]
|
||||
location locations @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
unit units @relation(fields: [unit_id], references: [code_unit], onDelete: Cascade, onUpdate: NoAction)
|
||||
|
|
Loading…
Reference in New Issue