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 // First check for "Jabatan:" pattern explicitly
for (int i = 0; i < allLines.length; i++) { for (int i = 0; i < allLines.length; i++) {
String line = allLines[i].toLowerCase(); String line = allLines[i].toLowerCase();
if (line.contains('jabatan')) { if (line.contains('pangkat')) {
if (line.contains(':')) { if (line.contains(':')) {
String position = line.split(':')[1].trim(); String position = line.split(':')[1].trim();
extractedInfo['jabatan'] = _normalizeCase(position); extractedInfo['pangkat'] = _normalizeCase(position);
print( print(
'Found position from "jabatan:" pattern: ${extractedInfo['jabatan']}', 'Found position from "jabatan:" pattern: ${extractedInfo['pangkat']}',
); );
return; return;
} }
@ -955,9 +955,9 @@ class AzureOCRService {
// Check next line // Check next line
if (i + 1 < allLines.length) { if (i + 1 < allLines.length) {
String nextLine = allLines[i + 1].trim(); String nextLine = allLines[i + 1].trim();
extractedInfo['jabatan'] = _normalizeCase(nextLine); extractedInfo['pangkat'] = _normalizeCase(nextLine);
print( print(
'Found position from line after "jabatan": ${extractedInfo['jabatan']}', 'Found position from line after "jabatan": ${extractedInfo['pangkat']}',
); );
return; return;
} }
@ -988,8 +988,8 @@ class AzureOCRService {
// Check if the entire line is a position // Check if the entire line is a position
for (String position in commonPositions) { for (String position in commonPositions) {
if (line == position) { if (line == position) {
extractedInfo['jabatan'] = position; extractedInfo['pangkat'] = position;
print('Found exact position match: ${extractedInfo['jabatan']}'); print('Found exact position match: ${extractedInfo['pangkat']}');
return; return;
} }
} }
@ -998,8 +998,8 @@ class AzureOCRService {
for (String position in commonPositions) { for (String position in commonPositions) {
if (line.contains(position)) { if (line.contains(position)) {
// Extract just the position part (this is more complex for real cards) // Extract just the position part (this is more complex for real cards)
extractedInfo['jabatan'] = position; extractedInfo['pangkat'] = position;
print('Found position in line: ${extractedInfo['jabatan']}'); print('Found position in line: ${extractedInfo['pangkat']}');
return; return;
} }
} }
@ -1008,7 +1008,7 @@ class AzureOCRService {
// Special handling for the sample data provided // Special handling for the sample data provided
if (allLines.length >= 4 && if (allLines.length >= 4 &&
allLines[3].trim().toUpperCase() == 'BRIGADIR') { allLines[3].trim().toUpperCase() == 'BRIGADIR') {
extractedInfo['jabatan'] = 'BRIGADIR'; extractedInfo['pangkat'] = 'BRIGADIR';
print('Found position (BRIGADIR) at line 3'); print('Found position (BRIGADIR) at line 3');
return; return;
} }
@ -1018,8 +1018,8 @@ class AzureOCRService {
String line = allLines[i].trim().toUpperCase(); String line = allLines[i].trim().toUpperCase();
for (String position in commonPositions) { for (String position in commonPositions) {
if (line == position || line.contains(position)) { if (line == position || line.contains(position)) {
extractedInfo['jabatan'] = position; extractedInfo['pangkat'] = position;
print('Found position in full scan: ${extractedInfo['jabatan']}'); print('Found position in full scan: ${extractedInfo['pangkat']}');
return; return;
} }
} }

View File

@ -42,7 +42,7 @@ class SignInController extends GetxController {
// Navigate to sign up screen // Navigate to sign up screen
void goToSignUp() { void goToSignUp() {
Get.toNamed(AppRoutes.signupWithRole); Get.toNamed(AppRoutes.roleSelection);
} }
// Clear error messages // Clear error messages

View File

@ -41,15 +41,23 @@ class FormRegistrationController extends GetxController {
late final OfficerInfoController? officerInfoController; late final OfficerInfoController? officerInfoController;
late final UnitInfoController? unitInfoController; 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(); final storage = GetStorage();
// Current step index // Loading state for form operations
final RxInt currentStep = 0.obs; final RxBool isLoading = false.obs;
// Total number of steps (depends on role) // Form key for validation
late final int totalSteps; GlobalKey<FormState>? formKey;
// User metadata model (kept for backward compatibility) // User metadata model (kept for backward compatibility)
final Rx<UserMetadataModel> userMetadata = UserMetadataModel().obs; final Rx<UserMetadataModel> userMetadata = UserMetadataModel().obs;
@ -61,9 +69,6 @@ class FormRegistrationController extends GetxController {
// Officer data (kept for backward compatibility) // Officer data (kept for backward compatibility)
final Rx<OfficerModel?> officerModel = Rx<OfficerModel?>(null); final Rx<OfficerModel?> officerModel = Rx<OfficerModel?>(null);
// Loading state
final RxBool isLoading = false.obs;
// Form submission states // Form submission states
final RxBool isSubmitting = RxBool(false); final RxBool isSubmitting = RxBool(false);
final RxString submitMessage = RxString(''); final RxString submitMessage = RxString('');
@ -308,11 +313,9 @@ class FormRegistrationController extends GetxController {
if (isOfficer) { if (isOfficer) {
officerInfoController = Get.find<OfficerInfoController>(); officerInfoController = Get.find<OfficerInfoController>();
unitInfoController = Get.find<UnitInfoController>(); unitInfoController = Get.find<UnitInfoController>();
totalSteps = TNum.totalStepOfficer;
} else { } else {
officerInfoController = null; officerInfoController = null;
unitInfoController = null; unitInfoController = null;
totalSteps = TNum.totalStepViewer;
} }
} }
@ -562,196 +565,59 @@ class FormRegistrationController extends GetxController {
// Get step titles based on role // Get step titles based on role
List<String> getStepTitles() { List<String> getStepTitles() {
if (selectedRole.value?.isOfficer ?? false) { 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 { } 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 // Navigate to the next step
T? getStepData<T>() { void nextStep() async {
switch (T) { // Validate the current form first
case PersonalInfoData: if (formKey?.currentState?.validate() ?? false) {
return registrationData.value.personalInfo as T?; // If this is the last step, submit the form
case IdCardVerificationData: if (currentStep.value == totalSteps - 1) {
return registrationData.value.idCardVerification as T?; await submitRegistration();
case SelfieVerificationData: } else {
return registrationData.value.selfieVerification as T?; // Otherwise, go to the next step
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
currentStep.value++; currentStep.value++;
} else { }
// submitForm();
} }
} }
void clearPreviousStepErrors() { // Navigate to the previous step
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
void previousStep() { void previousStep() {
if (currentStep.value > 0) { if (currentStep.value > 0) {
// Clear previous step errors
clearPreviousStepErrors();
// Decrement step
currentStep.value--; currentStep.value--;
} }
} }
// Go to specific step // Go to a specific step
void goToStep(int step) { void goToStep(int step) {
if (step >= 0 && step < totalSteps) { if (step >= 0 && step < totalSteps) {
// Only allow going to a step if all previous steps are valid currentStep.value = step;
bool canProceed = true;
for (int i = 0; i < step; i++) {
currentStep.value = i;
if (!validateCurrentStep()) {
canProceed = false;
break;
} }
} }
if (canProceed) { // Submit registration data
currentStep.value = step; 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) { if (isIdCardValid.value) {
hasConfirmedIdCard.value = true; hasConfirmedIdCard.value = true;
// Log storage data for debugging clearErrors(); // Clear any previous errors
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)}');
});
} }
} }

View File

@ -62,16 +62,37 @@ class OfficerInfoController extends GetxController {
Rx<DateTime?> selectedValidUntil = Rx<DateTime?>(null); Rx<DateTime?> selectedValidUntil = Rx<DateTime?>(null);
Rx<DateTime?> selectedDateOfBirth = Rx<DateTime?>(null); Rx<DateTime?> selectedDateOfBirth = Rx<DateTime?>(null);
// Dark mode reactive state
final Rx<bool> isDarkMode = false.obs;
final isDark = Get.isDarkMode;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
// Initialize isDarkMode with current value
_updateThemeMode();
initRepositories(); initRepositories();
// Fetch units after ensuring repositories are set up // Fetch units after ensuring repositories are set up
getAvailableUnits(); 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() { void initRepositories() {
// Check if repositories are already registered with GetX // Check if repositories are already registered with GetX
unitRepository = Get.find<UnitRepository>(); unitRepository = Get.find<UnitRepository>();
@ -172,6 +193,48 @@ class OfficerInfoController extends GetxController {
isValid = false; 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) { if (unitIdController.text.isEmpty) {
unitIdError.value = 'Please select a unit'; unitIdError.value = 'Please select a unit';
isValid = false; isValid = false;
@ -181,8 +244,21 @@ class OfficerInfoController extends GetxController {
return isValid; return isValid;
} }
void updateOfficer(OfficerModel officer, UserMetadataModel metadata) async { void submitRegistration(
OfficerInfoController controller,
// FormRegistrationController mainController,
GlobalKey<FormState> formKey,
) async {
try { 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(); TCircularFullScreenLoader.openLoadingDialog();
final isConnected = await NetworkManager.instance.isConnected(); final isConnected = await NetworkManager.instance.isConnected();
@ -195,49 +271,57 @@ class OfficerInfoController extends GetxController {
return; return;
} }
// Validate the form before proceeding // No need to validate again, already done above
if (!validate(null)) { 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( TLoaders.errorSnackBar(
title: 'Validation Error', title: 'Role Error',
message: 'Please fix the errors in the form before submitting.', message: 'User does not have a valid role assigned.',
); );
TCircularFullScreenLoader.stopLoading(); TCircularFullScreenLoader.stopLoading();
return; return;
} }
final data = officer.copyWith( OfficerModel officer = await OfficerRepository.instance.getOfficerById(
nrp: nrpController.text, userId,
name: nameController.text,
rank: rankController.text,
position: positionController.text,
phone: phoneController.text,
unitId: unitIdController.text,
validUntil: selectedValidUntil.value,
placeOfBirth: placeOfBirthController.text,
dateOfBirth: selectedDateOfBirth.value,
); );
Logger().i('Updating officer with data: ${data.toJson()}'); // 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( final updatedOfficer = await OfficerRepository.instance.updateOfficer(
// data, updateOfficer,
// ); );
// if (updatedOfficer == null) { if (updatedOfficer == null) {
// TLoaders.errorSnackBar( TLoaders.errorSnackBar(
// title: 'Update Failed', title: 'Update Failed',
// message: 'Failed to update officer information. Please try again.', message: 'Failed to update officer information. Please try again.',
// ); );
// TCircularFullScreenLoader.stopLoading(); TCircularFullScreenLoader.stopLoading();
// return; return;
// } }
// final userMetadata = await UserRepository.instance.updateProfileStatus('completed');
// metadata.copyWith(profileStatus: 'completed').toAuthMetadataJson();
// await UserRepository.instance.updateUserMetadata(userMetadata);
// TLoaders.successSnackBar( // TLoaders.successSnackBar(
// title: 'Update Successful', // title: 'Update Successful',
@ -245,18 +329,22 @@ class OfficerInfoController extends GetxController {
// ); // );
// resetForm(); // resetForm();
// TCircularFullScreenLoader.stopLoading(); TCircularFullScreenLoader.stopLoading();
// Get.off( Get.off(
// () => StateScreen( () => StateScreen(
// title: 'Officer Information Created', title: 'Officer Information Created',
// subtitle: 'Officer information has been successfully create.', subtitle: 'Officer information has been successfully created.',
// primaryButtonTitle: 'Back to signin', primaryButtonTitle: 'Back to signin',
// image: TImages.womanHuggingEarth, image:
// showButton: true, isDarkMode.value
// onPressed: () => AuthenticationRepository.instance.screenRedirect(), ? TImages.womanHuggingEarthDark
// ), : TImages.womanHuggingEarth,
// ); isSvg: true,
showButton: true,
onPressed: () => AuthenticationRepository.instance.screenRedirect(),
),
);
} catch (e) { } catch (e) {
logger.e('Error updating officer: $e'); logger.e('Error updating officer: $e');
TCircularFullScreenLoader.stopLoading(); 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/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/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/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/shared/widgets/indicators/step_indicator/step_indicator.dart';
import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/constants/sizes.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) { Widget _buildStepContent(FormRegistrationController controller) {
final isOfficer = controller.selectedRole.value?.isOfficer ?? false; 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) { switch (controller.currentStep.value) {
case 0: case 0:
return const PersonalInfoStep(); return const PersonalInfoStep();
@ -163,11 +130,10 @@ class FormRegistrationScreen extends StatelessWidget {
case 2: case 2:
return const SelfieVerificationStep(); return const SelfieVerificationStep();
case 3: case 3:
return isOfficer return const IdentityVerificationStep();
? const OfficerInfoStep()
: const IdentityVerificationStep();
default: default:
return const SizedBox.shrink(); 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/auth_divider.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/password_field.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/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/shared/widgets/text/custom_text_field.dart';
import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/constants/image_strings.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_source_dialog.dart';
import 'package:sigap/src/shared/widgets/image_upload/image_uploader.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/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/ocr_result_card.dart';
import 'package:sigap/src/shared/widgets/verification/validation_message_card.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/colors.dart';
@ -146,11 +147,50 @@ class IdCardVerificationStep extends StatelessWidget {
// Tips Section // Tips Section
const SizedBox(height: TSizes.spaceBtwItems), const SizedBox(height: TSizes.spaceBtwItems),
_buildIdCardTips(idCardType), _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) { Widget _buildHeader(BuildContext context, String idCardType) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, 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/id-card-verification/id_card_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/officer-information/officer_info_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/officer-information/officer_info_controller.dart';
import 'package:sigap/src/shared/widgets/form/form_section_header.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/shared/widgets/text/custom_text_field.dart';
import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/constants/sizes.dart'; import 'package:sigap/src/utils/constants/sizes.dart';
@ -20,7 +21,9 @@ class OfficerInfoStep extends StatelessWidget {
final mainController = Get.find<FormRegistrationController>(); final mainController = Get.find<FormRegistrationController>();
mainController.formKey = formKey; mainController.formKey = formKey;
// Check if KTA data exists and populate fields if available final isSubmitting = false.obs;
final submissionError = ''.obs;
_populateFieldsFromKta( _populateFieldsFromKta(
controller, controller,
mainController.idCardVerificationController, mainController.idCardVerificationController,
@ -53,7 +56,6 @@ class OfficerInfoStep extends StatelessWidget {
controller.nrpError.value = ''; controller.nrpError.value = '';
}, },
), ),
_buildErrorText(controller.nrpError),
// Name field // Name field
CustomTextField( CustomTextField(
@ -74,7 +76,6 @@ class OfficerInfoStep extends StatelessWidget {
controller.nameError.value = ''; controller.nameError.value = '';
}, },
), ),
_buildErrorText(controller.nameError),
// Rank field // Rank field
CustomTextField( CustomTextField(
@ -89,10 +90,10 @@ class OfficerInfoStep extends StatelessWidget {
controller.rankError.value = ''; controller.rankError.value = '';
}, },
), ),
_buildErrorText(controller.rankError),
// Position field // Position field
CustomTextField( CustomTextField(
label: 'Position', label: 'Position',
controller: controller.positionController, controller: controller.positionController,
validator: (v) => TValidators.validateUserInput('Position', v, 100), validator: (v) => TValidators.validateUserInput('Position', v, 100),
@ -103,8 +104,8 @@ class OfficerInfoStep extends StatelessWidget {
controller.positionController.text = value; controller.positionController.text = value;
controller.positionError.value = ''; controller.positionError.value = '';
}, },
), ),
_buildErrorText(controller.positionError),
// Phone field // Phone field
CustomTextField( CustomTextField(
@ -120,7 +121,6 @@ class OfficerInfoStep extends StatelessWidget {
controller.phoneError.value = ''; controller.phoneError.value = '';
}, },
), ),
_buildErrorText(controller.phoneError),
// Place of Birth field // Place of Birth field
CustomTextField( CustomTextField(
@ -136,7 +136,6 @@ class OfficerInfoStep extends StatelessWidget {
controller.placeOfBirthError.value = ''; controller.placeOfBirthError.value = '';
}, },
), ),
_buildErrorText(controller.placeOfBirthError),
// Date of Birth field // Date of Birth field
_buildDateField( _buildDateField(
@ -145,15 +144,10 @@ class OfficerInfoStep extends StatelessWidget {
label: 'Date of Birth', label: 'Date of Birth',
textController: controller.dateOfBirthController, textController: controller.dateOfBirthController,
errorValue: controller.dateOfBirthError, errorValue: controller.dateOfBirthError,
hintText: 'YYYY-MM-DD', hintText: 'Select your birth date',
initialDate: DateTime.now().subtract( dateType: DateFieldType.birthDate,
const Duration(days: 365 * 18),
), // Default to 18 years ago
firstDate: DateTime(1950),
lastDate: DateTime.now(),
onDateSelected: controller.setDateOfBirth, onDateSelected: controller.setDateOfBirth,
), ),
_buildErrorText(controller.dateOfBirthError),
// Valid Until field // Valid Until field
_buildDateField( _buildDateField(
@ -162,15 +156,10 @@ class OfficerInfoStep extends StatelessWidget {
label: 'Valid Until', label: 'Valid Until',
textController: controller.validUntilController, textController: controller.validUntilController,
errorValue: controller.validUntilError, errorValue: controller.validUntilError,
hintText: 'YYYY-MM-DD', hintText: 'Select expiry date',
initialDate: DateTime.now().add( dateType: DateFieldType.validUntil,
const Duration(days: 365),
), // Default to 1 year from now
firstDate: DateTime.now(),
lastDate: DateTime(2100),
onDateSelected: controller.setValidUntilDate, onDateSelected: controller.setValidUntilDate,
), ),
_buildErrorText(controller.validUntilError),
const SizedBox(height: TSizes.spaceBtwSections), const SizedBox(height: TSizes.spaceBtwSections),
@ -182,8 +171,9 @@ class OfficerInfoStep extends StatelessWidget {
const SizedBox(height: TSizes.spaceBtwItems), const SizedBox(height: TSizes.spaceBtwItems),
// Unit dropdown // Unit dropdown with error styling
_buildUnitDropdown( _buildUnitDropdown(
context,
controller, controller,
mainController.idCardVerificationController, 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( void _populateFieldsFromKta(
OfficerInfoController controller, OfficerInfoController controller,
IdCardVerificationController mainController, IdCardVerificationController mainController,
) { ) {
// Check if KTA data exists in the main controller
final KtaModel? ktaData = mainController.ktaModel.value; final KtaModel? ktaData = mainController.ktaModel.value;
if (ktaData != null) { if (ktaData != null) {
controller.populateFromKta(ktaData); controller.populateFromKta(ktaData);
} }
} }
// Helper to build error text consistently // error text with better styling
Widget _buildErrorText(RxString errorValue) { Widget _buildErrorText(RxString errorValue) {
return Obx( return Obx(
() => () =>
errorValue.value.isNotEmpty errorValue.value.isNotEmpty
? Padding( ? Container(
padding: const EdgeInsets.only(top: 8.0), 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( child: Text(
errorValue.value, errorValue.value,
style: TextStyle(color: Colors.red[700], fontSize: 12), style: TextStyle(
color: TColors.error,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
), ),
) )
: const SizedBox.shrink(), : const SizedBox.shrink(),
); );
} }
// Build date picker field // date picker with proper date ranges and error styling
Widget _buildDateField({ Widget _buildDateField({
required BuildContext context, required BuildContext context,
required OfficerInfoController controller, required OfficerInfoController controller,
@ -267,36 +282,85 @@ class OfficerInfoStep extends StatelessWidget {
required TextEditingController textController, required TextEditingController textController,
required RxString errorValue, required RxString errorValue,
required String hintText, required String hintText,
required DateTime initialDate, required DateFieldType dateType,
required DateTime firstDate,
required DateTime lastDate,
required Function(DateTime) onDateSelected, 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( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
CustomTextField( // Label
label: label, Text(
controller: textController, label,
readOnly: true, // Make read-only since we use date picker style: theme.textTheme.bodyMedium?.copyWith(
errorText: errorValue.value, fontWeight: FontWeight.w500,
hintText: hintText, color: hasError ? TColors.error : null,
suffixIcon: IconButton( ),
icon: const Icon(Icons.calendar_today), ),
onPressed: () async { const SizedBox(height: TSizes.sm),
// Date field container
GestureDetector(
onTap: () async {
final date = await showDatePicker( final date = await showDatePicker(
context: context, context: context,
initialDate: initialDate, initialDate:
textController.text.isNotEmpty
? DateTime.tryParse(textController.text) ?? initialDate
: initialDate,
firstDate: firstDate, firstDate: firstDate,
lastDate: lastDate, lastDate: lastDate,
helpText:
dateType == DateFieldType.birthDate
? 'Select your birth date'
: 'Select expiry date',
builder: (context, child) { builder: (context, child) {
return Theme( return Theme(
data: Theme.of(context).copyWith( data: theme.copyWith(
colorScheme: ColorScheme.light( colorScheme: theme.colorScheme.copyWith(
primary: Theme.of(context).primaryColor, primary: isDark ? TColors.accent : TColors.primary,
onPrimary: Colors.white, onPrimary: isDark ? TColors.primary : TColors.accent,
surface: Colors.white, surface: isDark ? Colors.grey[800] : TColors.accent,
onSurface: Colors.black, onSurface: isDark ? TColors.accent : Colors.black,
), ),
), ),
child: child!, child: child!,
@ -306,50 +370,114 @@ class OfficerInfoStep extends StatelessWidget {
if (date != null) { if (date != null) {
onDateSelected(date); 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( Widget _buildUnitDropdown(
BuildContext context,
OfficerInfoController controller, OfficerInfoController controller,
IdCardVerificationController idCardController, 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( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Label - using context directly // Label
Builder( Text(
builder: (context) {
return Text(
'Select Unit:', 'Select Unit:',
style: Theme.of( style: theme.textTheme.bodyMedium?.copyWith(
context, fontWeight: FontWeight.w500,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), color: hasError ? TColors.error : null,
); ),
},
), ),
const SizedBox(height: TSizes.sm), const SizedBox(height: TSizes.sm),
// Dropdown using Builder to access current context (and theme) // Loading state
Builder( if (controller.isLoadingUnits.value)
builder: (context) { Container(
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(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: TSizes.md, horizontal: TSizes.md,
vertical: TSizes.md, vertical: TSizes.md,
@ -362,64 +490,91 @@ class OfficerInfoStep extends StatelessWidget {
child: Center( child: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(TSizes.sm), padding: const EdgeInsets.all(TSizes.sm),
child: CircularProgressIndicator( child: CircularProgressIndicator(color: theme.primaryColor),
color: theme.primaryColor,
), ),
), ),
), )
); // No units available
} else if (controller.availableUnits.isEmpty)
Container(
if (controller.availableUnits.isEmpty) {
return Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: TSizes.md, horizontal: TSizes.md,
vertical: TSizes.md, vertical: TSizes.md,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: theme.dividerColor), border: Border.all(
color: hasError ? TColors.error : theme.dividerColor,
width: hasError ? 2 : 1,
),
borderRadius: borderRadius, borderRadius: borderRadius,
color: fillColor, 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', 'No units available',
style: theme.textTheme.bodyMedium?.copyWith( 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( final selectedUnit = controller.availableUnits.firstWhereOrNull(
(unit) => unit.codeUnit == controller.unitIdController.text, (unit) => unit.codeUnit == controller.unitIdController.text,
); );
// If units are loaded and we have KTA data with a police unit, // Auto-select matching unit from KTA data
// try to find a matching unit and select it
if (controller.availableUnits.isNotEmpty && if (controller.availableUnits.isNotEmpty &&
controller.unitIdController.text.isEmpty) { controller.unitIdController.text.isEmpty) {
final ktaUnit = final ktaUnit = idCardController.ktaModel.value?.policeUnit ?? '';
idCardController.ktaModel.value?.policeUnit ?? '';
// More flexible matching logic to find the best matching unit
final matchingUnit = controller.availableUnits.firstWhereOrNull( final matchingUnit = controller.availableUnits.firstWhereOrNull(
(unit) => (unit) =>
// Try exact match first
unit.name.toLowerCase() == ktaUnit.toLowerCase() || unit.name.toLowerCase() == ktaUnit.toLowerCase() ||
// Then try contains match unit.name.toLowerCase().contains(ktaUnit.toLowerCase()) ||
unit.name.toLowerCase().contains(
ktaUnit.toLowerCase(),
) ||
// Or if the KTA unit contains the available unit name
ktaUnit.toLowerCase().contains(unit.name.toLowerCase()), ktaUnit.toLowerCase().contains(unit.name.toLowerCase()),
); );
if (matchingUnit != null) { 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 // Dropdown Selection Button
GestureDetector( GestureDetector(
onTap: () { onTap: () {
// Toggle dropdown visibility
controller.isUnitDropdownOpen.toggle(); controller.isUnitDropdownOpen.toggle();
if (hasError) {
controller.unitIdError.value =
''; // Clear error on interaction
}
}, },
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@ -439,17 +597,34 @@ class OfficerInfoStep extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all( border: Border.all(
color: color:
controller.isUnitDropdownOpen.value hasError
? TColors.error
: (controller.isUnitDropdownOpen.value
? theme.primaryColor ? theme.primaryColor
: theme.dividerColor, : theme.dividerColor),
width: width:
controller.isUnitDropdownOpen.value ? 1.5 : 1, hasError || controller.isUnitDropdownOpen.value ? 2 : 1,
), ),
borderRadius: borderRadius, borderRadius: borderRadius,
color: fillColor, color: fillColor,
), ),
child: Row( child: Row(
children: [ 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( Expanded(
child: Text( child: Text(
selectedUnit != null selectedUnit != null
@ -457,11 +632,13 @@ class OfficerInfoStep extends StatelessWidget {
: 'Select Unit', : 'Select Unit',
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: color:
selectedUnit != null hasError
? TColors.error
: (selectedUnit != null
? theme.textTheme.bodyMedium?.color ? theme.textTheme.bodyMedium?.color
: (isDark : (isDark
? Colors.grey[400] ? Colors.grey[400]
: Colors.grey[600]), : Colors.grey[600])),
), ),
), ),
), ),
@ -470,11 +647,13 @@ class OfficerInfoStep extends StatelessWidget {
? Icons.keyboard_arrow_up ? Icons.keyboard_arrow_up
: Icons.keyboard_arrow_down, : Icons.keyboard_arrow_down,
color: color:
controller.isUnitDropdownOpen.value hasError
? TColors.error
: (controller.isUnitDropdownOpen.value
? theme.primaryColor ? theme.primaryColor
: (isDark : (isDark
? Colors.grey[400] ? Colors.grey[400]
: Colors.grey[600]), : Colors.grey[600])),
), ),
], ],
), ),
@ -490,14 +669,14 @@ class OfficerInfoStep extends StatelessWidget {
borderRadius: borderRadius, borderRadius: borderRadius,
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity( color: Colors.black.withOpacity(isDark ? 0.3 : 0.1),
isDark ? 0.3 : 0.1,
),
blurRadius: 8, blurRadius: 8,
offset: const Offset(0, 2), 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), constraints: const BoxConstraints(maxHeight: 250),
child: ClipRRect( child: ClipRRect(
@ -509,13 +688,14 @@ class OfficerInfoStep extends StatelessWidget {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final unit = controller.availableUnits[index]; final unit = controller.availableUnits[index];
final isSelected = final isSelected =
unit.codeUnit == unit.codeUnit == controller.unitIdController.text;
controller.unitIdController.text;
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
controller.onUnitSelected(unit); controller.onUnitSelected(unit);
controller.isUnitDropdownOpen.value = false; controller.isUnitDropdownOpen.value = false;
controller.unitIdError.value =
''; // Clear error on selection
}, },
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@ -530,15 +710,12 @@ class OfficerInfoStep extends StatelessWidget {
) )
: Colors.transparent, : Colors.transparent,
border: border:
index < index < controller.availableUnits.length - 1
controller
.availableUnits
.length -
1
? Border( ? Border(
bottom: BorderSide( bottom: BorderSide(
color: theme.dividerColor color: theme.dividerColor.withOpacity(
.withOpacity(0.5), 0.5,
),
width: 0.5, width: 0.5,
), ),
) )
@ -546,7 +723,6 @@ class OfficerInfoStep extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ children: [
// Unit Icon
Icon( Icon(
isSelected isSelected
? Icons.shield ? Icons.shield
@ -560,20 +736,14 @@ class OfficerInfoStep extends StatelessWidget {
size: 20, size: 20,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
// Unit Name
Expanded( Expanded(
child: Text( child: Text(
'${unit.name} (${unit.type.name})', '${unit.name} (${unit.type.name})',
style: theme.textTheme.bodyMedium style: theme.textTheme.bodyMedium?.copyWith(
?.copyWith(
color: color:
isSelected isSelected
? theme.primaryColor ? theme.primaryColor
: theme : theme.textTheme.bodyMedium?.color,
.textTheme
.bodyMedium
?.color,
fontWeight: fontWeight:
isSelected isSelected
? FontWeight.bold ? FontWeight.bold
@ -581,8 +751,6 @@ class OfficerInfoStep extends StatelessWidget {
), ),
), ),
), ),
// Checkmark for selected item
if (isSelected) if (isSelected)
Icon( Icon(
Icons.check, Icons.check,
@ -599,26 +767,18 @@ class OfficerInfoStep extends StatelessWidget {
), ),
// Selected unit display // Selected unit display
if (selectedUnit != null && if (selectedUnit != null && !controller.isUnitDropdownOpen.value)
!controller.isUnitDropdownOpen.value)
Container( Container(
margin: const EdgeInsets.only( margin: const EdgeInsets.only(top: TSizes.spaceBtwInputFields),
top: TSizes.spaceBtwInputFields,
),
padding: const EdgeInsets.all(TSizes.md), padding: const EdgeInsets.all(TSizes.md),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.primaryColor.withOpacity( color: theme.primaryColor.withOpacity(isDark ? 0.2 : 0.1),
isDark ? 0.2 : 0.1,
),
borderRadius: borderRadius, borderRadius: borderRadius,
border: Border.all(color: theme.primaryColor), border: Border.all(color: theme.primaryColor),
), ),
child: Row( child: Row(
children: [ children: [
Icon( Icon(Icons.shield_outlined, color: theme.primaryColor),
Icons.shield_outlined,
color: theme.primaryColor,
),
const SizedBox(width: TSizes.sm), const SizedBox(width: TSizes.sm),
Expanded( Expanded(
child: Column( child: Column(
@ -628,9 +788,7 @@ class OfficerInfoStep extends StatelessWidget {
'Selected Unit', 'Selected Unit',
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
color: color:
isDark isDark ? Colors.grey[400] : Colors.grey[600],
? Colors.grey[400]
: Colors.grey[600],
), ),
), ),
Text( Text(
@ -650,16 +808,36 @@ class OfficerInfoStep extends StatelessWidget {
], ],
), ),
), ),
],
);
},
);
}
// Error message // Helper method to format date for display
_buildErrorText(controller.unitIdError), 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/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/image_upload/image_uploader.dart';
import 'package:sigap/src/shared/widgets/info/tips_container.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/shared/widgets/verification/validation_message_card.dart';
import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/constants/sizes.dart'; import 'package:sigap/src/utils/constants/sizes.dart';
@ -67,11 +68,53 @@ class SelfieVerificationStep extends StatelessWidget {
// Tips container // Tips container
_buildSelfieTips(), _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) { Widget _buildDevelopmentModeIndicator(FacialVerificationService service) {
if (!service.skipFaceVerification) return const SizedBox.shrink(); if (!service.skipFaceVerification) return const SizedBox.shrink();

View File

@ -26,7 +26,7 @@ class AuthButton extends StatelessWidget {
onPressed: isLoading ? null : onPressed, onPressed: isLoading ? null : onPressed,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor ?? Theme.of(context).primaryColor, backgroundColor: backgroundColor ?? Theme.of(context).primaryColor,
foregroundColor: textColor ?? Colors.white, foregroundColor: textColor ?? Theme.of(context).colorScheme.onPrimary,
elevation: 1, elevation: 1,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(TSizes.buttonRadius), borderRadius: BorderRadius.circular(TSizes.buttonRadius),

View File

@ -1,12 +1,12 @@
import 'package:sigap/src/features/personalization/data/models/models/roles_model.dart'; import 'package:sigap/src/features/personalization/data/models/models/roles_model.dart';
class OfficerModel { class OfficerModel {
final String id; final String? id;
final String unitId; final String? unitId;
final String roleId; final String? roleId;
final String? patrolUnitId; final String? patrolUnitId;
final String nrp; final dynamic nrp; // Changed to dynamic to handle both string and int
final String name; final String? name;
final String? rank; final String? rank;
final String? position; final String? position;
final String? phone; final String? phone;
@ -19,14 +19,20 @@ class OfficerModel {
final DateTime? createdAt; final DateTime? createdAt;
final DateTime? updatedAt; final DateTime? updatedAt;
final RoleModel? role; 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({ OfficerModel({
required this.id, this.id,
required this.unitId, this.unitId,
required this.roleId, this.roleId,
this.patrolUnitId, this.patrolUnitId,
required this.nrp, this.nrp,
required this.name, this.name,
this.rank, this.rank,
this.position, this.position,
this.phone, this.phone,
@ -39,23 +45,28 @@ class OfficerModel {
this.createdAt, this.createdAt,
this.updatedAt, this.updatedAt,
this.role, this.role,
this.bannedReason,
this.bannedUntil,
this.isBanned = false,
this.panicStrike = 0,
this.spoofingAttempts = 0,
}); });
// Create an OfficerModel instance from a JSON object // Create an OfficerModel instance from a JSON object
factory OfficerModel.fromJson(Map<String, dynamic> json) { factory OfficerModel.fromJson(Map<String, dynamic> json) {
return OfficerModel( return OfficerModel(
id: json['id'] as String, id: json['id'],
unitId: json['unit_id'] as String, unitId: json['unit_id'],
roleId: json['role_id'] as String, roleId: json['role_id'],
patrolUnitId: json['patrol_unit_id'] as String?, patrolUnitId: json['patrol_unit_id'],
nrp: json['nrp'] as String, nrp: json['nrp'], // Accept as dynamic
name: json['name'] as String, name: json['name'],
rank: json['rank'] as String?, rank: json['rank'],
position: json['position'] as String?, position: json['position'],
phone: json['phone'] as String?, phone: json['phone'],
email: json['email'] as String?, email: json['email'],
avatar: json['avatar'] as String?, avatar: json['avatar'],
placeOfBirth: json['birth_place'] as String?, placeOfBirth: json['place_of_birth'] ?? json['birth_place'],
dateOfBirth: dateOfBirth:
json['date_of_birth'] != null json['date_of_birth'] != null
? DateTime.parse(json['date_of_birth'] as String) ? DateTime.parse(json['date_of_birth'] as String)
@ -64,7 +75,7 @@ class OfficerModel {
json['valid_until'] != null json['valid_until'] != null
? DateTime.parse(json['valid_until'] as String) ? DateTime.parse(json['valid_until'] as String)
: null, : null,
qrCode: json['qr_code'] as String?, qrCode: json['qr_code'],
createdAt: createdAt:
json['created_at'] != null json['created_at'] != null
? DateTime.parse(json['created_at'] as String) ? DateTime.parse(json['created_at'] as String)
@ -73,10 +84,15 @@ class OfficerModel {
json['updated_at'] != null json['updated_at'] != null
? DateTime.parse(json['updated_at'] as String) ? DateTime.parse(json['updated_at'] as String)
: null, : null,
role: // New fields
json['roles'] != null bannedReason: json['banned_reason'],
? RoleModel.fromJson(json['roles'] as Map<String, dynamic>) bannedUntil:
json['banned_until'] != null
? DateTime.parse(json['banned_until'] as String)
: null, : null,
isBanned: json['is_banned'] ?? false,
panicStrike: json['panic_strike'] ?? 0,
spoofingAttempts: json['spoofing_attempts'] ?? 0,
); );
} }
@ -94,23 +110,60 @@ class OfficerModel {
'phone': phone, 'phone': phone,
'email': email, 'email': email,
'avatar': avatar, 'avatar': avatar,
'birth_place': placeOfBirth, 'place_of_birth': placeOfBirth,
'date_of_birth': dateOfBirth?.toIso8601String(), 'date_of_birth': dateOfBirth?.toIso8601String(),
'valid_until': validUntil?.toIso8601String(), 'valid_until': validUntil?.toIso8601String(),
'qr_code': qrCode, 'qr_code': qrCode,
'created_at': createdAt?.toIso8601String(), 'created_at': createdAt?.toIso8601String(),
'updated_at': updatedAt?.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 // Create a copy of the OfficerModel with updated fields
OfficerModel copyWith({ OfficerModel copyWith({
String? id, String? id,
String? unitId, String? unitId,
String? roleId, String? roleId,
String? patrolUnitId, String? patrolUnitId,
String? nrp, dynamic nrp, // Changed to dynamic
String? name, String? name,
String? rank, String? rank,
String? position, String? position,
@ -124,6 +177,11 @@ class OfficerModel {
DateTime? createdAt, DateTime? createdAt,
DateTime? updatedAt, DateTime? updatedAt,
RoleModel? role, RoleModel? role,
String? bannedReason,
DateTime? bannedUntil,
bool? isBanned,
int? panicStrike,
int? spoofingAttempts,
}) { }) {
return OfficerModel( return OfficerModel(
id: id ?? this.id, id: id ?? this.id,
@ -144,6 +202,40 @@ class OfficerModel {
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,
role: role ?? this.role, 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 @override
String toString() { String toString() {
return 'OfficerModel(id: $id, name: $name, nrp: $nrp)'; return 'OfficerModel(id: $id, name: $name, nrp: $nrp)';

View File

@ -51,6 +51,18 @@ class RoleSelectionController extends GetxController {
role.name.toLowerCase() == 'officer', role.name.toLowerCase() == 'officer',
) )
.toList(); .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) { } catch (e) {
TLoaders.errorSnackBar( TLoaders.errorSnackBar(
title: 'Error', title: 'Error',

View File

@ -138,7 +138,7 @@ class RoleSelectionScreen extends StatelessWidget {
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: SvgPicture.asset( child: SvgPicture.asset(
TImages.homeOffice, isDark ? TImages.communicationDark : TImages.communication,
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
fit: BoxFit.cover, 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({ UserMetadataModel copyWith({
bool? isOfficer, bool? isOfficer,
String? userId, String? userId,
@ -186,22 +191,49 @@ class UserMetadataModel {
UserModel? viewerData, UserModel? viewerData,
Map<String, dynamic>? additionalData, Map<String, dynamic>? additionalData,
}) { }) {
final newIsOfficer = isOfficer ?? this.isOfficer;
return UserMetadataModel( return UserMetadataModel(
isOfficer: isOfficer ?? this.isOfficer, isOfficer: newIsOfficer,
userId: userId ?? this.userId, userId: userId ?? this.userId,
roleId: roleId ?? this.roleId, roleId: roleId ?? this.roleId,
profileStatus: profileStatus ?? this.profileStatus, profileStatus: profileStatus ?? this.profileStatus,
email: email ?? this.email, email: email ?? this.email,
officerData: officerData ?? this.officerData, // Only include officer data if the model is for an officer
viewerData: viewerData ?? this.viewerData, 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, 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) // MARK: - Computed properties (getters)
/// Primary identifier (NRP for officers, NIK for users) /// 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) /// User's NIK (delegated to viewerData if available)
String? get nik => viewerData?.profile?.nik; String? get nik => viewerData?.profile?.nik;
@ -211,7 +243,7 @@ class UserMetadataModel {
/// User's name (delegated to appropriate model or fallback to email) /// User's name (delegated to appropriate model or fallback to email)
String? get name { String? get name {
if (isOfficer && officerData?.name.isNotEmpty == true) { if (isOfficer && officerData?.name?.isNotEmpty == true) {
return officerData!.name; return officerData!.name;
} }
if (!isOfficer && viewerData?.profile?.fullName?.isNotEmpty == true) { if (!isOfficer && viewerData?.profile?.fullName?.isNotEmpty == true) {
@ -238,10 +270,10 @@ class UserMetadataModel {
final errors = <String>[]; final errors = <String>[];
if (isOfficer) { if (isOfficer) {
if (officerData?.nrp.isEmpty != false) { if (officerData?.nrp == null) {
errors.add('NRP is required for officers'); 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'); errors.add('Unit ID is required for officers');
} }
} else { } else {

View File

@ -1,5 +1,6 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.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/auth/data/repositories/authentication_repository.dart';
import 'package:sigap/src/features/daily-ops/data/models/models/officers_model.dart'; import 'package:sigap/src/features/daily-ops/data/models/models/officers_model.dart';
import 'package:sigap/src/utils/exceptions/exceptions.dart'; import 'package:sigap/src/utils/exceptions/exceptions.dart';
@ -64,6 +65,8 @@ class OfficerRepository extends GetxController {
return null; return null;
} }
Logger().i('Officer updated successfully: $updatedOfficer');
// updatedOfficer is a List, so we take the first item and convert it // updatedOfficer is a List, so we take the first item and convert it
return OfficerModel.fromJson(updatedOfficer); return OfficerModel.fromJson(updatedOfficer);
} on PostgrestException catch (error) { } on PostgrestException catch (error) {
@ -91,6 +94,8 @@ class OfficerRepository extends GetxController {
.eq('id', officerId) .eq('id', officerId)
.single(); .single();
Logger().i('Fetched officer data: $officerData');
return OfficerModel.fromJson(officerData); return OfficerModel.fromJson(officerData);
} on PostgrestException catch (error) { } on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code!); throw TExceptions.fromCode(error.code!);

View File

@ -116,8 +116,13 @@ class UserRepository extends GetxController {
if (!isAuthenticated) { if (!isAuthenticated) {
throw 'User not authenticated'; 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) { } on AuthException catch (e) {
_logger.e('AuthException in updateUserMetadata: ${e.message}'); _logger.e('AuthException in updateUserMetadata: ${e.message}');
throw TExceptions(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 // Update user email
Future<void> updateUserEmail(String newEmail) async { Future<void> updateUserEmail(String newEmail) async {
try { try {

View File

@ -1,4 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'dart:ui'; // Add this import for ImageFilter
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
@ -379,28 +380,51 @@ class ImageUploader extends StatelessWidget {
} }
Widget _defaultErrorOverlay() { 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, height: 200,
width: double.infinity, width: double.infinity,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd - 2), 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: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: TColors.error.withOpacity(0.2),
shape: BoxShape.circle,
),
child: Icon(
Icons.error_outline, Icons.error_outline,
color: TColors.error, color: Colors.white,
size: TSizes.iconLg, size: TSizes.iconLg,
), ),
),
const SizedBox(height: TSizes.sm), const SizedBox(height: TSizes.sm),
Text( Text(
'Invalid Image', 'Invalid Image',
style: TextStyle( style: TextStyle(
color: TColors.error, color: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16,
), ),
), ),
const SizedBox(height: TSizes.xs), const SizedBox(height: TSizes.xs),
@ -410,7 +434,7 @@ class ImageUploader extends StatelessWidget {
errorMessage ?? 'Please try another image', errorMessage ?? 'Please try another image',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
color: TColors.error, color: Colors.white.withOpacity(0.9),
fontSize: TSizes.fontSizeSm, 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/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:lottie/lottie.dart'; import 'package:lottie/lottie.dart';
import 'package:sigap/src/shared/styles/spacing_styles.dart'; import 'package:sigap/src/shared/styles/spacing_styles.dart';
import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/colors.dart';
@ -20,6 +21,7 @@ class StateScreen extends StatelessWidget {
this.primaryButtonTitle = 'Continue', this.primaryButtonTitle = 'Continue',
this.onSecondaryPressed, this.onSecondaryPressed,
this.isLottie = false, this.isLottie = false,
this.isSvg = false,
}); });
final String? image; final String? image;
@ -28,6 +30,7 @@ class StateScreen extends StatelessWidget {
final String primaryButtonTitle; final String primaryButtonTitle;
final String secondaryTitle; final String secondaryTitle;
final bool? isLottie; final bool? isLottie;
final bool isSvg;
final VoidCallback? onPressed; final VoidCallback? onPressed;
final VoidCallback? onSecondaryPressed; final VoidCallback? onSecondaryPressed;
final bool showButton; final bool showButton;
@ -50,7 +53,7 @@ class StateScreen extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// Image, Icon, or Lottie // Image, Icon, Lottie, or SVG
if (icon != null) if (icon != null)
Icon( Icon(
icon, icon,
@ -62,6 +65,15 @@ class StateScreen extends StatelessWidget {
image!, image!,
width: THelperFunctions.screenWidth() * 0.8, 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) else if (image != null)
Image( Image(
image: AssetImage(image!), image: AssetImage(image!),

View File

@ -20,6 +20,7 @@ class CustomTextField extends StatelessWidget {
final void Function(String)? onChanged; final void Function(String)? onChanged;
final Color? accentColor; final Color? accentColor;
final Color? fillColor; final Color? fillColor;
final InputDecoration? decoration; // New parameter for custom decoration
const CustomTextField({ const CustomTextField({
super.key, super.key,
@ -40,8 +41,8 @@ class CustomTextField extends StatelessWidget {
this.onChanged, this.onChanged,
this.accentColor, this.accentColor,
this.fillColor, this.fillColor,
this.decoration, // Add to constructor
}) : assert( }) : assert(
// Fix the assertion to avoid duplicate conditions
controller == null || initialValue == null, controller == null || initialValue == null,
'Either provide a controller or an initialValue, not both', 'Either provide a controller or an initialValue, not both',
); );
@ -59,8 +60,10 @@ class CustomTextField extends StatelessWidget {
? (isDark ? Colors.grey[800]! : Colors.grey[200]!) ? (isDark ? Colors.grey[800]! : Colors.grey[200]!)
: fillColor ?? (isDark ? TColors.dark : TColors.lightContainer); : fillColor ?? (isDark ? TColors.dark : TColors.lightContainer);
// Get the common input decoration for both cases // Get the input decoration - either custom or default
final inputDecoration = _getInputDecoration( final inputDecoration =
decoration ??
_getInputDecoration(
context, context,
effectiveAccentColor, effectiveAccentColor,
isDark, isDark,

View File

@ -2,5 +2,6 @@ class TNum {
// Auth Number // Auth Number
static const int oneTimePassword = 6; static const int oneTimePassword = 6;
static const int totalStepViewer = 4; 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 location_id String @db.Uuid
name String @db.VarChar(100) name String @db.VarChar(100)
type String @db.VarChar(50) type String @db.VarChar(50)
category patrol_unit_category? @default(group)
member_count Int? @default(0)
status String @db.VarChar(50) status String @db.VarChar(50)
radius Float radius Float
created_at DateTime @default(now()) @db.Timestamptz(6) created_at DateTime @default(now()) @db.Timestamptz(6)
id String @id @unique @db.VarChar(100) id String @id @unique @db.VarChar(100)
category patrol_unit_category? @default(group)
member_count Int? @default(0)
members officers[] members officers[]
location locations @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: NoAction) 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) unit units @relation(fields: [unit_id], references: [code_unit], onDelete: Cascade, onUpdate: NoAction)