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:
vergiLgood1 2025-05-27 00:44:32 +07:00
parent 407233916b
commit 569e1d6049
24 changed files with 2378 additions and 1869 deletions

View File

@ -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;
}
}

View File

@ -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

View File

@ -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;
}
}

View File

@ -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
}
}

View File

@ -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();

View File

@ -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();
}
}
}
}

View File

@ -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';

View File

@ -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,

View File

@ -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 }

View File

@ -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();

View File

@ -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),

View File

@ -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)';

View File

@ -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',

View File

@ -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,

View File

@ -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 {

View File

@ -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!);

View File

@ -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 {

View File

@ -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 {
),
);
}
}

View File

@ -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),
),
),
],
),
],
);
}
}

View File

@ -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!),

View File

@ -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,

View File

@ -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
}

View File

@ -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)