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: currentStep.value++;
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++;
} 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,66 +104,36 @@ 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;
switch (controller.currentStep.value) { // Different step content for officer vs viewer
case 0: if (isOfficer) {
return const PersonalInfoStep(); // Officer registration flow (3 steps)
case 1: switch (controller.currentStep.value) {
return const IdCardVerificationStep(); case 0:
case 2: return const IdCardVerificationStep();
return const SelfieVerificationStep(); case 1:
case 3: return const SelfieVerificationStep();
return isOfficer case 2:
? const OfficerInfoStep() return const OfficerInfoStep();
: const IdentityVerificationStep(); default:
default: return const SizedBox.shrink();
return const SizedBox.shrink(); }
} else {
// Viewer registration flow (4 steps)
switch (controller.currentStep.value) {
case 0:
return const PersonalInfoStep();
case 1:
return const IdCardVerificationStep();
case 2:
return const SelfieVerificationStep();
case 3:
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/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

@ -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';
@ -54,7 +55,7 @@ class OfficerRepository extends GetxController {
final updatedOfficer = final updatedOfficer =
await _supabase await _supabase
.from('officers') .from('officers')
.update(data) .update(data)
.eq('id', currentUserId!) .eq('id', currentUserId!)
.select() .select()
@ -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,43 +380,69 @@ class ImageUploader extends StatelessWidget {
} }
Widget _defaultErrorOverlay() { Widget _defaultErrorOverlay() {
return Container( return ClipRRect(
height: 200, borderRadius: BorderRadius.circular(TSizes.borderRadiusMd - 2),
width: double.infinity, child: BackdropFilter(
decoration: BoxDecoration( filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd - 2), child: Container(
color: TColors.error.withOpacity(0.2), height: 200,
), width: double.infinity,
child: Center( decoration: BoxDecoration(
child: Column( borderRadius: BorderRadius.circular(TSizes.borderRadiusMd - 2),
mainAxisAlignment: MainAxisAlignment.center, color: Colors.black.withOpacity(0.5),
children: [ ),
Icon( child: Container(
Icons.error_outline, decoration: BoxDecoration(
color: TColors.error, gradient: LinearGradient(
size: TSizes.iconLg, begin: Alignment.topCenter,
), end: Alignment.bottomCenter,
const SizedBox(height: TSizes.sm), colors: [
Text( TColors.error.withOpacity(0.4),
'Invalid Image', Colors.black.withOpacity(0.7),
style: TextStyle( ],
color: TColors.error,
fontWeight: FontWeight.bold,
), ),
), ),
const SizedBox(height: TSizes.xs), child: Center(
Padding( child: Column(
padding: const EdgeInsets.symmetric(horizontal: TSizes.md), mainAxisAlignment: MainAxisAlignment.center,
child: Text( children: [
errorMessage ?? 'Please try another image', Container(
textAlign: TextAlign.center, padding: const EdgeInsets.all(10),
style: TextStyle( decoration: BoxDecoration(
color: TColors.error, color: TColors.error.withOpacity(0.2),
fontSize: TSizes.fontSizeSm, shape: BoxShape.circle,
), ),
child: Icon(
Icons.error_outline,
color: Colors.white,
size: TSizes.iconLg,
),
),
const SizedBox(height: TSizes.sm),
Text(
'Invalid Image',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: TSizes.xs),
Padding(
padding: const EdgeInsets.symmetric(horizontal: TSizes.md),
child: Text(
errorMessage ?? 'Please try another image',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: TSizes.fontSizeSm,
),
),
),
],
), ),
), ),
], ),
), ),
), ),
); );
@ -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)