From 1a6eefe6e30262d715e4e1c031c42c8295c09b37 Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Sun, 25 May 2025 21:11:30 +0700 Subject: [PATCH] update user dan user profile --- .../authentication_repository.dart | 4 +- .../main/registration_form_controller.dart | 208 +++++++- .../identity_verification_controller.dart | 55 +-- .../face_liveness_detection_controller.dart | 2 +- .../identity_verification_step.dart | 315 +++++++++--- .../liveness_detection_screen.dart | 90 ++-- .../widgets/camera_preview_widget.dart | 454 ++++++++++-------- .../widgets/countdown_overlay_widget.dart | 74 +-- .../widgets/verification_progress_widget.dart | 228 ++++++--- .../data/models/models/officers_model.dart | 6 +- .../data/models/models/profile_model.dart | 8 +- .../models/models/user_metadata_model.dart | 2 +- .../data/models/models/users_model.dart | 4 +- .../data/repositories/profile_repository.dart | 45 +- .../data/repositories/users_repository.dart | 70 ++- .../widgets/state_screeen/state_screen.dart | 135 +++--- 16 files changed, 1071 insertions(+), 629 deletions(-) diff --git a/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart b/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart index 74007dd..5d5be65 100644 --- a/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart +++ b/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart @@ -83,7 +83,7 @@ class AuthenticationRepository extends GetxController { final bool isFirstTime = storage.read('isFirstTime') ?? true; final isEmailVerified = session?.user.emailConfirmedAt != null; final isProfileComplete = - session?.user.userMetadata?['profile_status'] == 'complete'; + session?.user.userMetadata?['profile_status'] == 'completed'; // Log the current state for debugging Logger().d( @@ -679,7 +679,7 @@ class AuthenticationRepository extends GetxController { // Convert to UserModel final userMetadataModel = UserMetadataModel.fromInitUserMetadata( completeData, - profileStatus: 'complete', + profileStatus: 'completed', ); // First update auth metadata diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup/main/registration_form_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup/main/registration_form_controller.dart index 9b45b3a..7472402 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup/main/registration_form_controller.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup/main/registration_form_controller.dart @@ -4,6 +4,7 @@ import 'package:get_storage/get_storage.dart'; import 'package:logger/logger.dart'; import 'package:sigap/src/cores/services/supabase_service.dart'; import 'package:sigap/src/features/auth/data/models/registration_data_model.dart'; +import 'package:sigap/src/features/auth/data/repositories/authentication_repository.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/identity-verification/identity_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/officer-information/officer_info_controller.dart'; @@ -14,9 +15,12 @@ import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/vie import 'package:sigap/src/features/daily-ops/data/models/index.dart'; import 'package:sigap/src/features/personalization/data/models/index.dart'; import 'package:sigap/src/features/personalization/data/models/models/user_metadata_model.dart'; +import 'package:sigap/src/features/personalization/data/repositories/profile_repository.dart'; import 'package:sigap/src/features/personalization/data/repositories/roles_repository.dart'; import 'package:sigap/src/features/personalization/data/repositories/users_repository.dart'; +import 'package:sigap/src/shared/widgets/state_screeen/state_screen.dart'; import 'package:sigap/src/utils/constants/num_int.dart'; +import 'package:sigap/src/utils/helpers/network_manager.dart'; import 'package:sigap/src/utils/popups/loaders.dart'; class FormRegistrationController extends GetxController { @@ -225,6 +229,8 @@ class FormRegistrationController extends GetxController { void _initializeControllers() { final isOfficer = registrationData.value.isOfficer; + Logger().d('Initializing controllers with isOfficer: $isOfficer'); + // Clear existing controllers first to prevent duplicates _clearExistingControllers(); @@ -716,7 +722,7 @@ class FormRegistrationController extends GetxController { // Fixed missing parenthesis currentStep.value++; } else { - submitForm(); + // submitForm(); } } @@ -771,38 +777,192 @@ class FormRegistrationController extends GetxController { } } - // Submit the entire form using centralized registration data - Future submitForm() async { - if (!validateCurrentStep()) { - print('Form validation failed for step ${currentStep.value}'); - return false; - } + // Update officer information + // void updateOfficerRegistrationData() async { + // try { + // isSubmitting.value = true; + // submitMessage.value = 'Submitting officer registration...'; - if (currentStep.value < totalSteps - 1) { - nextStep(); - return false; - } + // final isConnected = await NetworkManager.instance.isConnected(); + // if (!isConnected) { + // TLoaders.errorSnackBar( + // title: 'No Internet Connection', + // message: 'Please check your internet connection and try again.', + // ); + // isSubmitting.value = false; + // return; + // } + // // Get user ID and other key information + // final userId = AuthenticationRepository.instance.authUser!.id; + // final email = AuthenticationRepository.instance.authUser!.email; + // final roleId = + // AuthenticationRepository.instance.authUser!.userMetadata?['role_id'] + // as String? ?? + // ''; + + // // Create officer model + // final officer = OfficerModel( + // id: userId, + // unitId: unitInfoController?.unitIdController.text ?? '', + // roleId: roleId, + // nrp: officerInfoController?.nrpController.text ?? '', + // name: + // '${personalInfoController.firstNameController.text} ${personalInfoController.lastNameController.text}', + // rank: officerInfoController?.rankController.text, + // position: unitInfoController?.positionController.text, + // phone: personalInfoController.phoneController.text, + // email: email, + // placeOfBirth: identityController.placeOfBirthController.text, + // dateOfBirth: _parseBirthDate( + // identityController.birthDateController.text, + // ), + // ); + + // // Prepare auth metadata update + // final authMetadata = { + // 'profile_status': 'completed', + // 'is_officer': true, + // 'name': officer.name, + // 'nrp': officer.nrp, + // 'unit_id': officer.unitId, + // 'unit_name': _getUnitNameFromId(officer.unitId), + // }; + + // // Perform updates in a specific order: + + // // 1. Update auth metadata + // try { + // await UserRepository.instance.updateUserMetadata( + // authMetadata, + // ); + // Logger().d('Officer auth metadata updated successfully'); + // } catch (e) { + // Logger().e('Error updating officer auth metadata: $e'); + // throw Exception('Failed to update officer auth metadata: $e'); + // } + + // // 2. Update or create officer record + // try { + // await OfficerRepository.instance.updateOrCreateOfficer( + // officer.toJson(), + // ); + // Logger().d('Officer data updated successfully'); + // } catch (e) { + // Logger().e('Error updating officer data: $e'); + // throw Exception('Failed to update officer data: $e'); + // } + + // isSubmitSuccess.value = true; + // submitMessage.value = 'Officer registration completed successfully'; + // } catch (e) { + // Logger().e('Error updating officer data: $e'); + // isSubmitSuccess.value = false; + // submitMessage.value = + // 'Failed to complete officer registration: ${e.toString()}'; + // } finally { + // isSubmitting.value = false; + // } + // } + + // Storing registration data to Supabase + void updateUserRegistrationData() async { try { isSubmitting.value = true; - submitMessage.value = 'Submitting your registration...'; - final result = await saveRegistrationData(); - - if (result) { - isSubmitSuccess.value = true; - submitMessage.value = 'Registration completed successfully!'; - } else { - isSubmitSuccess.value = false; - submitMessage.value = 'Registration failed. Please try again.'; + final isConnected = await NetworkManager.instance.isConnected(); + if (!isConnected) { + TLoaders.errorSnackBar( + title: 'No Internet Connection', + message: 'Please check your internet connection and try again.', + ); + isSubmitting.value = false; + return; } - return result; + final currentUser = await UserRepository.instance.getUserById( + AuthenticationRepository.instance.authUser!.id, + ); + + final updateUser = currentUser.copyWith( + phone: personalInfoController.phoneController.text, + updatedAt: DateTime.now(), + ); + + final updateProfile = currentUser.profile?.copyWith( + nik: identityController.nikController.text, + firstName: personalInfoController.firstNameController.text, + lastName: personalInfoController.lastNameController.text, + address: { + 'full_address': personalInfoController.addressController.text, + }, + placeOfBirth: identityController.placeOfBirthController.text, + birthDate: _parseBirthDate(identityController.birthDateController.text), + ); + + Logger().d('Created user model for update: ${updateUser.toJson()}'); + + // Check if NIK is already registered (but ignore if it's the current user's NIK) + final isNikTaken = await UserRepository.instance.isNikExists( + updateProfile!.nik!, + ); + + if (isNikTaken) { + TLoaders.errorSnackBar( + title: 'Error', + message: 'NIK already registered to another user!', + ); + isSubmitting.value = false; + return; + } + + // Update user model in database + final userRepository = UserRepository.instance; + final profileRepository = ProfileRepository.instance; + + final userUpdated = await userRepository.updateUser(updateUser); + await profileRepository.updateProfile(updateProfile); + + if (userUpdated == null) { + TLoaders.errorSnackBar( + title: 'Error', + message: 'Failed to update user profile. Please try again.', + ); + isSubmitting.value = false; + return; + } + + + // Update user metadata with common profile info + final metadata = + UserMetadataModel(profileStatus: 'completed').toAuthMetadataJson(); + + // Update metadata first + await userRepository.updateUserMetadata(metadata); + + Logger().d('User metadata updated successfully'); + + isSubmitSuccess.value = true; + submitMessage.value = 'Registration completed successfully'; + + Get.off( + () => StateScreen( + title: 'Registration Completed', + subtitle: 'Your registration has been successfully completed.', + icon: Icons.check_circle, + showButton: true, + primaryButtonTitle: 'Go to Home', + onPressed: () => AuthenticationRepository.instance.screenRedirect(), + ), + ); } catch (e) { - print('Error submitting form: $e'); + Logger().e('Error updating registration data: $e'); isSubmitSuccess.value = false; - submitMessage.value = 'Error during registration: $e'; - return false; + submitMessage.value = 'Failed to complete registration: ${e.toString()}'; + TLoaders.errorSnackBar( + title: 'Error', + message: 'Failed to complete registration: ${e.toString()}', + ); } finally { isSubmitting.value = false; } diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup/step/identity-verification/identity_verification_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup/step/identity-verification/identity_verification_controller.dart index 6fcb7a8..e9b176a 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup/step/identity-verification/identity_verification_controller.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup/step/identity-verification/identity_verification_controller.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:logger/logger.dart'; import 'package:sigap/src/features/auth/data/models/face_model.dart'; -import 'package:sigap/src/features/auth/data/models/registration_data_model.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/main/registration_form_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/selfie-verification/facial_verification_controller.dart'; @@ -545,60 +544,28 @@ class IdentityVerificationController extends GetxController { } // Save registration data using the centralized model - Future saveRegistrationData() async { + void saveRegistrationData() async { try { - isSavingData.value = true; - dataSaveMessage.value = 'Saving your registration data...'; - - // Final validation - if (!validate(null)) { - dataSaveMessage.value = 'Please fix the errors before submitting'; - return false; + // Call the appropriate registration update function based on user type + if (isOfficer) { + // For officers, use the officer registration update function + // mainController.updateOfficerRegistrationData(); } - // Update the registration data with identity verification data - final currentIdentityData = IdentityVerificationData( - nik: nikController.text, - fullName: fullNameController.text, - placeOfBirth: placeOfBirthController.text, - birthDate: birthDateController.text, - gender: selectedGender.value ?? '', - address: addressController.text, - ); - - // Update the centralized registration data - mainController.updateStepData( - currentIdentityData, - ); - - // Update summary with final form data - _updateSummaryWithFormData(); - - // Send the data to the main controller for submission - final result = await mainController.saveRegistrationData( - summaryData: summaryData, - ); - - if (result) { - isDataSaved.value = true; - dataSaveMessage.value = 'Registration data saved successfully!'; - } else { - isDataSaved.value = false; - dataSaveMessage.value = - 'Failed to save registration data. Please try again.'; - } - - return result; + // For regular users, call the three required updates + mainController.updateUserRegistrationData(); + } catch (e) { isDataSaved.value = false; dataSaveMessage.value = 'Error saving registration data: $e'; - print('Error saving registration data: $e'); - return false; + Logger().e('Error saving registration data: $e'); } finally { isSavingData.value = false; } } + + @override void onClose() { // Dispose form controllers diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup/step/selfie-verification/face_liveness_detection_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup/step/selfie-verification/face_liveness_detection_controller.dart index 57e6588..c833ba5 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup/step/selfie-verification/face_liveness_detection_controller.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup/step/selfie-verification/face_liveness_detection_controller.dart @@ -55,7 +55,7 @@ class FaceLivenessController extends GetxController { final successfulSteps = [].obs; // Countdown timer state - final countdownSeconds = 5.obs; + final countdownSeconds = 3.obs; Timer? _countdownTimer; // Image processing diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/identity-verification/identity_verification_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/identity-verification/identity_verification_step.dart index 3be7739..096085a 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/identity-verification/identity_verification_step.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/identity-verification/identity_verification_step.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/main/registration_form_controller.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/id-card-verification/id_card_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/identity-verification/identity_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/unit_info_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/selfie-verification/selfie_verification_controller.dart'; -import 'package:sigap/src/features/auth/presentasion/pages/signup/step/identity-verification/widgets/id_info_form.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/viewer-information/personal_info_controller.dart'; import 'package:sigap/src/shared/widgets/form/form_section_header.dart'; import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/sizes.dart'; @@ -16,8 +19,9 @@ class IdentityVerificationStep extends StatelessWidget { final formKey = GlobalKey(); final mainController = Get.find(); final controller = Get.find(); - - Get.find(); + // final personalInfoController = Get.find(); + // final idCardController = Get.find(); + // final selfieController = Get.find(); mainController.formKey = formKey; @@ -39,40 +43,14 @@ class IdentityVerificationStep extends StatelessWidget { const SizedBox(height: TSizes.spaceBtwItems), // Verification Progress Card - GetBuilder( - builder: (ctrl) => _buildVerificationProgressCard(ctrl), - ), - + // GetBuilder( + // builder: (ctrl) => _buildVerificationProgressCard(ctrl), + // ), const SizedBox(height: TSizes.spaceBtwItems), - // Form section header - Container( - padding: const EdgeInsets.only( - top: TSizes.spaceBtwItems, - bottom: TSizes.sm, - ), - decoration: BoxDecoration( - border: Border( - top: BorderSide(color: Colors.grey.withOpacity(0.2), width: 1), - ), - ), - child: FormSectionHeader( - title: 'Confirm Identity Information', - subtitle: - isOfficer - ? 'Please verify the pre-filled information from your KTA' - : 'Please verify the pre-filled information from your KTP', - ), - ), + // Data Summary Section + _buildDataSummaryCards(context, isOfficer), - const SizedBox(height: TSizes.spaceBtwItems), - - // ID Card Info Form (with pre-filled data) - IdInfoForm(controller: controller, isOfficer: isOfficer), - - const SizedBox(height: TSizes.spaceBtwSections), - - // Save & Submit Button GetBuilder( id: 'saveButton', builder: @@ -80,7 +58,7 @@ class IdentityVerificationStep extends StatelessWidget { onPressed: ctrl.isSavingData.value ? null - : () => _submitRegistrationData(ctrl, context), + : () => _submitRegistrationData(controller, context), style: ElevatedButton.styleFrom( backgroundColor: TColors.primary, foregroundColor: Colors.white, @@ -138,6 +116,223 @@ class IdentityVerificationStep extends StatelessWidget { ); } + Widget _buildDataSummaryCards(BuildContext context, bool isOfficer) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // User Profile Information Summary + _buildUserProfileSummaryCard(context), + + const SizedBox(height: TSizes.spaceBtwItems), + + // ID Card Information Summary + _buildIdCardSummaryCard(context, isOfficer), + + // Officer Information Card (if applicable) + if (isOfficer) ...[ + const SizedBox(height: TSizes.spaceBtwItems), + _buildOfficerInfoSummaryCard(context), + ], + + const SizedBox(height: TSizes.spaceBtwSections), + + // Divider to separate summary from the form + Divider(color: Colors.grey.withOpacity(0.3), thickness: 1), + + const SizedBox(height: TSizes.spaceBtwItems), + ], + ); + } + + // User Profile Information Summary Card (based on ProfileModel) + Widget _buildUserProfileSummaryCard(BuildContext context) { + final personalInfoController = Get.find(); + final idCardController = Get.find(); + final selfieController = Get.find(); + + return _buildSummaryCard( + context: context, + icon: Icons.person, + title: 'User Profile Information', + content: [ + _buildInfoRow( + 'First Name', + personalInfoController.firstNameController.text, + ), + _buildInfoRow( + 'Last Name', + personalInfoController.lastNameController.text, + ), + _buildInfoRow('Phone', personalInfoController.phoneController.text), + _buildInfoRow( + 'Bio', + personalInfoController.bioController.text.isEmpty + ? 'Not provided' + : personalInfoController.bioController.text, + ), + _buildInfoRow('Address', personalInfoController.addressController.text), + ], + isVerified: true, + ); + } + + // ID Card Information Summary Card (based on KTP/KTA models) + Widget _buildIdCardSummaryCard(BuildContext context, bool isOfficer) { + final idCardController = Get.find(); + + final List idCardInfo = []; + String cardTitle = ''; + + if (isOfficer && idCardController.ktaModel.value != null) { + cardTitle = 'Police Officer Card (KTA)'; + final kta = idCardController.ktaModel.value!; + idCardInfo.addAll([ + _buildInfoRow('NRP', kta.formattedNrp), + _buildInfoRow('Name', kta.name), + _buildInfoRow('Police Unit', kta.policeUnit), + if (kta.extraData != null && kta.extraData!['pangkat'] != null) + _buildInfoRow('Rank', kta.extraData!['pangkat']), + ]); + } else if (!isOfficer && idCardController.ktpModel.value != null) { + cardTitle = 'National ID (KTP)'; + final ktp = idCardController.ktpModel.value!; + idCardInfo.addAll([ + _buildInfoRow('NIK', ktp.formattedNik), + _buildInfoRow('Name', ktp.name), + _buildInfoRow('Birth Place', ktp.birthPlace), + _buildInfoRow('Birth Date', ktp.birthDate), + _buildInfoRow('Gender', ktp.gender), + ]); + } + + return _buildSummaryCard( + context: context, + icon: Icons.badge, + title: cardTitle, + content: idCardInfo, + isVerified: idCardController.isIdCardValid.value, + ); + } + + // Officer Information Summary Card (based on OfficerModel) + Widget _buildOfficerInfoSummaryCard(BuildContext context) { + final officerInfoController = Get.find(); + final unitInfoController = Get.find(); + + final selectedUnit = unitInfoController.availableUnits.firstWhereOrNull( + (unit) => unit.codeUnit == unitInfoController.unitIdController.text, + ); + + return _buildSummaryCard( + context: context, + icon: Icons.security, + title: 'Officer Information', + content: [ + _buildInfoRow('NRP', officerInfoController.nrpController.text), + _buildInfoRow('Rank', officerInfoController.rankController.text), + _buildInfoRow('Position', unitInfoController.positionController.text), + _buildInfoRow( + 'Unit', + selectedUnit?.name ?? unitInfoController.unitIdController.text, + ), + ], + isVerified: true, + ); + } + + // Generic summary card builder + Widget _buildSummaryCard({ + required BuildContext context, + required IconData icon, + required String title, + required List content, + required bool isVerified, + }) { + return Card( + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), + side: BorderSide( + color: + isVerified + ? Colors.green.withOpacity(0.5) + : TColors.primary.withOpacity(0.5), + width: 1, + ), + ), + child: Padding( + padding: const EdgeInsets.all(TSizes.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Icon( + icon, + color: isVerified ? Colors.green : TColors.primary, + size: TSizes.iconMd, + ), + const SizedBox(width: TSizes.sm), + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: isVerified ? Colors.green : TColors.primary, + ), + ), + const Spacer(), + Icon( + isVerified ? Icons.check_circle : Icons.info_outline, + color: isVerified ? Colors.green : TColors.warning, + size: TSizes.iconSm, + ), + const SizedBox(width: TSizes.xs), + Text( + isVerified ? 'Verified' : 'Review', + style: TextStyle( + fontSize: TSizes.fontSizeXs, + color: isVerified ? Colors.green : TColors.warning, + ), + ), + ], + ), + const Divider(height: TSizes.spaceBtwItems), + + // Content + ...content, + ], + ), + ), + ); + } + + // Helper to build info rows in summary cards + Widget _buildInfoRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: TSizes.xs), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + '$label:', + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: TSizes.fontSizeSm, + color: TColors.textSecondary, + ), + ), + ), + Expanded( + child: Text(value, style: TextStyle(fontSize: TSizes.fontSizeSm)), + ), + ], + ), + ); + } + // Build Verification Progress Card Widget _buildVerificationProgressCard( IdentityVerificationController controller, @@ -196,7 +391,7 @@ class IdentityVerificationStep extends StatelessWidget { // ID Card status _buildVerificationItem( 'ID Card Verification', - controller.isIdCardVerified.value, + controller.extractedIdCardNumber!.isNotEmpty, ), // Selfie status @@ -281,50 +476,10 @@ class IdentityVerificationStep extends StatelessWidget { IdentityVerificationController controller, BuildContext context, ) async { - final formKey = FormRegistrationController().formKey; - // Validate form - if (!controller.validate(formKey)) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Please complete all required fields'), - backgroundColor: TColors.error, - ), - ); - return; - } - - // Save registration data - // final result = await controller.saveRegistrationData(); + + // Call saveRegistrationData with all the collected data + controller.saveRegistrationData(); - // if (result) { - // // Navigate to success page or show success dialog - // showDialog( - // context: context, - // barrierDismissible: false, - // builder: - // (context) => AlertDialog( - // title: Row( - // children: [ - // Icon(Icons.check_circle, color: Colors.green), - // SizedBox(width: TSizes.sm), - // Text('Registration Successful'), - // ], - // ), - // content: Text( - // 'Your registration has been submitted successfully. You will be notified once your account is verified.', - // ), - // actions: [ - // TextButton( - // onPressed: () { - // Navigator.of(context).pop(); - // // Navigate to login or home page - // Get.offAllNamed('/login'); - // }, - // child: Text('Go to Login'), - // ), - // ], - // ), - // ); - // } + // The rest of submission logic would be handled in the controller } } diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/selfie-verification/liveness_detection_screen.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/selfie-verification/liveness_detection_screen.dart index 55519da..ab94641 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/selfie-verification/liveness_detection_screen.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/selfie-verification/liveness_detection_screen.dart @@ -8,7 +8,6 @@ import 'package:sigap/src/features/auth/presentasion/pages/signup/step/selfie-ve import 'package:sigap/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/captured_selfie_view.dart'; import 'package:sigap/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/countdown_overlay_widget.dart'; import 'package:sigap/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/debug_panel.dart'; -import 'package:sigap/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/instruction_banner.dart'; import 'package:sigap/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/verification_progress_widget.dart'; import 'package:sigap/src/shared/widgets/state_screeen/state_screen.dart'; import 'package:sigap/src/utils/constants/colors.dart'; @@ -54,7 +53,8 @@ class LivenessDetectionPage extends StatelessWidget { } }, child: Scaffold( - backgroundColor: Colors.white, + backgroundColor: Colors.black, + extendBodyBehindAppBar: true, // Allow content behind app bar appBar: _buildAppBar(context, controller, selfieController), body: GetBuilder( builder: (_) { @@ -70,7 +70,7 @@ class LivenessDetectionPage extends StatelessWidget { return StateScreen( icon: Icons.camera_alt_outlined, title: 'Camera Error', - subtitle: 'Unable to access camera. Please try again later .', + subtitle: 'Unable to access camera. Please try again later.', ); } @@ -103,33 +103,30 @@ class LivenessDetectionPage extends StatelessWidget { return AppBar( elevation: 0, centerTitle: true, - backgroundColor: Colors.white, - title: const Text( + backgroundColor: Colors.transparent, // Transparent background + iconTheme: IconThemeData(color: Colors.white), // White icons + title: Text( 'Face Verification', style: TextStyle( - color: Colors.black87, + color: Colors.white, fontWeight: FontWeight.w600, fontSize: 18, ), ), - // Add debug button + // Debug button actions: [ IconButton( - icon: Icon(Icons.bug_report, color: TColors.warning), + icon: Icon(Icons.bug_report, color: Colors.white.withOpacity(0.8)), onPressed: () => showLivenessDebugPanel(context, controller, selfieController), ), ], leading: IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.black87), + icon: Icon(Icons.arrow_back, color: Colors.white), onPressed: () { dev.log('Back button pressed', name: 'LIVENESS_DEBUG'); - - // Ensure we clean up resources when going back controller.handleCancellation(); - - // This allows the user to go back to selfie verification step Get.back(); }, ), @@ -138,37 +135,29 @@ class LivenessDetectionPage extends StatelessWidget { // Camera initializing state UI Widget _buildCameraInitializingState(FaceLivenessController controller) { - return Obx(() { - if (controller.status.value == LivenessStatus.detectingFace) { - return Center( - child: Text( - 'Camera initialized successfully!', - style: TextStyle(fontSize: 16, color: Colors.green), - ), - ); - } - - return Center( + return Container( + color: Colors.black, + child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const CircularProgressIndicator( + CircularProgressIndicator( color: TColors.primary, strokeWidth: 3, ), - const SizedBox(height: 24), + SizedBox(height: 24), Text( 'Initializing camera...', style: TextStyle( fontSize: 16, - color: Colors.black87, + color: Colors.white70, fontWeight: FontWeight.w500, ), ), ], ), - ); - }); + ), + ); } // Main detection view UI with the new layout structure @@ -179,34 +168,27 @@ class LivenessDetectionPage extends StatelessWidget { final screenSize = MediaQuery.of(context).size; return Stack( + fit: StackFit.expand, children: [ - // Main content area with specified layout structure - Column( - children: [ - // 1. Header with instructions (smaller to give more space to camera) - Container( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: InstructionBanner(controller: controller), - ), - - // 2. Content area with camera preview (expanded to fill available space) - Expanded( - flex: 8, // Give most of the space to camera - child: CameraPreviewWidget( - controller: controller, - screenWidth: screenSize.width, - ), - ), - - // 3. Bottom verification progress list (small fixed height) - Container( - padding: const EdgeInsets.only(bottom: 16), - child: VerificationProgressWidget(controller: controller), - ), - ], + // Full screen camera preview + CameraPreviewWidget( + controller: controller, + screenWidth: screenSize.width, ), - // Overlay components + // Progress indicator at the bottom + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: EdgeInsets.only(bottom: 20), + color: Colors.black.withOpacity(0.5), + child: VerificationProgressWidget(controller: controller), + ), + ), + + // Overlay components including countdown CountdownOverlayWidget(controller: controller), ], ); diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/camera_preview_widget.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/camera_preview_widget.dart index 59b7d32..a9c17b7 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/camera_preview_widget.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/camera_preview_widget.dart @@ -1,11 +1,12 @@ import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_tabler_icons/flutter_tabler_icons.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/selfie-verification/face_liveness_detection_controller.dart'; import 'package:sigap/src/utils/constants/colors.dart'; +// Enum for bracket positions +enum BracketPosition { topLeft, topRight, bottomLeft, bottomRight } + class CameraPreviewWidget extends StatelessWidget { final FaceLivenessController controller; final double screenWidth; @@ -18,234 +19,199 @@ class CameraPreviewWidget extends StatelessWidget { @override Widget build(BuildContext context) { - final double screenHeight = MediaQuery.of(context).size.height; - // Calculate available height for camera preview - final double availableHeight = - screenHeight * 0.6; // Use 60% of screen height - - // Use the smallest dimension to ensure a square preview - final double previewSize = - availableHeight < screenWidth - ? availableHeight - : screenWidth * 0.92; // Use 92% of screen width if height is large + final screenHeight = MediaQuery.of(context).size.height; return Obx(() { // Check if controller is disposed if (controller.status.value == LivenessStatus.dispose) { - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Container( - width: previewSize, - height: previewSize, - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.05), - borderRadius: BorderRadius.circular(24), - ), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - TablerIcons.camera_off, - size: 48, - color: Colors.grey.shade600, - ), - const SizedBox(height: 16), - Text( - 'Camera is not available', - style: TextStyle(color: Colors.grey.shade700), - ), - ], - ), - ), - ), - ); + return _buildCameraPlaceholder(context, "Camera is not available"); } final bool isInitialized = controller.cameraController?.value.isInitialized ?? false; - final bool isActive = - true; // Always show camera when controller is initialized final bool isCountdown = controller.status.value == LivenessStatus.countdown; - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Stack( - alignment: Alignment.center, - children: [ - // Camera frame/background - Container( - width: previewSize, - height: previewSize, + if (!isInitialized) { + return _buildCameraPlaceholder(context, "Camera is initializing..."); + } + + // Calculate aspect ratio to maintain camera feed proportions + final cameraRatio = controller.cameraController!.value.aspectRatio; + + // Full screen camera preview with overlays + return Stack( + children: [ + // Camera feed takes the full available width and adjusts height based on aspect ratio + Positioned.fill( + child: ClipRRect( + child: Transform.scale( + scale: 1.1, // Slight zoom for better framing + child: AspectRatio( + aspectRatio: 1 / cameraRatio, + child: CameraPreview(controller.cameraController!), + ), + ), + ), + ), + + // Dark overlay for better visibility of UI elements + Positioned.fill( + child: Container( decoration: BoxDecoration( - color: Colors.black.withOpacity(0.05), - borderRadius: BorderRadius.circular(24), - border: Border.all(color: Colors.grey.shade300, width: 2), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 8, - spreadRadius: 0, - ), - ], + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withOpacity(0.3), + Colors.transparent, + Colors.transparent, + Colors.black.withOpacity(0.3), + ], + stops: const [0.0, 0.25, 0.75, 1.0], + ), + ), + ), + ), + + // Face outline guide with corner brackets + Center(child: _buildFaceGuideWithBrackets(screenWidth)), + + // Face indicator icon (shows when no face is detected) + if (!controller.isFaceInFrame.value) + Center( + child: Icon( + Icons.face, + color: Colors.white.withOpacity(0.7), + size: 48, ), ), - // Full camera feed - if (isInitialized && isActive) - SizedBox( - width: previewSize, - height: previewSize, - child: ClipRRect( - borderRadius: BorderRadius.circular(22), - child: Stack( - fit: StackFit.expand, - children: [ - Center( - child: SizedBox( - width: previewSize, - height: previewSize, - child: FittedBox( - fit: BoxFit.cover, - child: SizedBox( - width: - previewSize / - controller - .cameraController! - .value - .aspectRatio, - height: previewSize, - child: CameraPreview( - controller.cameraController!, - ), - ), - ), - ), - ), - // Overlay for better face visibility - Container( - decoration: BoxDecoration( - gradient: RadialGradient( - colors: [ - Colors.transparent, - Colors.black.withOpacity(0.2), - ], - stops: const [0.7, 1.0], - center: Alignment.center, - radius: 0.9, - ), - ), - ), - ], - ), - ), - ) - else - // Show placeholder when camera is not active - Container( - width: previewSize, - height: previewSize, + // Instructions banner at the bottom of the screen (fixed position) + Positioned( + bottom: screenHeight * 0.15, // Position from bottom + left: 0, + right: 0, + child: Center( + child: Container( + margin: EdgeInsets.symmetric(horizontal: 20), + padding: EdgeInsets.symmetric(vertical: 12, horizontal: 20), decoration: BoxDecoration( - color: Colors.black.withOpacity(0.05), - borderRadius: BorderRadius.circular(24), + color: Colors.black.withOpacity(0.5), + borderRadius: BorderRadius.circular(12), ), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.camera_alt_outlined, - size: 48, - color: Colors.grey.shade600, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(_getInstructionIcon(), color: Colors.white, size: 22), + SizedBox(width: 12), + Flexible( + child: Text( + _getActionText(), + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), ), - const SizedBox(height: 16), - Text( - 'Camera is initializing...', - style: TextStyle(color: Colors.grey.shade700), - ), - ], - ), - ), - ), - - // Scanning animation when not in countdown - if (isInitialized && isActive && !isCountdown) - Positioned( - top: previewSize * 0.2, // Position at 20% from the top - child: _buildScanningAnimation(previewSize), - ), - - // Face guide overlay - if (isInitialized && isActive) - Center( - child: Container( - width: - previewSize * - 0.7, // Make face guide 70% of the camera preview - height: previewSize * 0.7, - decoration: BoxDecoration( - border: Border.all( - color: _getFaceGuideColor(), - width: 2.5, - strokeAlign: BorderSide.strokeAlignOutside, ), - shape: BoxShape.circle, - ), - child: Obx( - () => - controller.isFaceInFrame.value - ? Center() - : Center( - child: Icon( - Icons.face, - color: Colors.white.withOpacity(0.7), - size: 48, - ), - ), - ), + ], ), ), - - // Instructions overlay - if (isInitialized && isActive && !isCountdown) - Positioned( - bottom: 20, - child: Container( - padding: EdgeInsets.symmetric(vertical: 8, horizontal: 16), - decoration: BoxDecoration( - color: Colors.black26, - borderRadius: BorderRadius.circular(16), - ), - child: Text( - _getActionText(), - style: TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ], - ), + ), + ), + ], ); }); } - // Build scanning animation widget - Widget _buildScanningAnimation(double previewSize) { + // Build face guide with corner brackets + Widget _buildFaceGuideWithBrackets(double screenWidth) { + final guideWidth = screenWidth * 0.7; + final guideHeight = + guideWidth * 1.2; // Make it slightly taller than wide for face + final color = _getFaceGuideColor(); + + return SizedBox( + width: guideWidth, + height: guideHeight, + child: Stack( + children: [ + // Invisible container to define the area + Container( + width: guideWidth, + height: guideHeight, + decoration: BoxDecoration(color: Colors.transparent), + ), + + // Top-left corner + Positioned( + top: 0, + left: 0, + child: _buildCornerBracket(color, BracketPosition.topLeft), + ), + + // Top-right corner + Positioned( + top: 0, + right: 0, + child: _buildCornerBracket(color, BracketPosition.topRight), + ), + + // Bottom-left corner + Positioned( + bottom: 0, + left: 0, + child: _buildCornerBracket(color, BracketPosition.bottomLeft), + ), + + // Bottom-right corner + Positioned( + bottom: 0, + right: 0, + child: _buildCornerBracket(color, BracketPosition.bottomRight), + ), + ], + ), + ); + } + + // Build a corner bracket + Widget _buildCornerBracket(Color color, BracketPosition position) { + final bracketThickness = 3.0; + final bracketLength = 25.0; + + return SizedBox( + width: bracketLength, + height: bracketLength, + child: CustomPaint( + painter: CornerBracketPainter( + color: color, + strokeWidth: bracketThickness, + position: position, + ), + ), + ); + } + + // Placeholder widget when camera isn't available/initialized + Widget _buildCameraPlaceholder(BuildContext context, String message) { return Container( - width: previewSize * 0.8, - height: 3, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.transparent, - TColors.primary.withOpacity(0.8), - Colors.transparent, + color: Colors.black, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.camera_alt_outlined, + size: 48, + color: Colors.white.withOpacity(0.7), + ), + const SizedBox(height: 16), + Text( + message, + style: TextStyle(color: Colors.white.withOpacity(0.7)), + ), ], ), ), @@ -256,7 +222,7 @@ class CameraPreviewWidget extends StatelessWidget { String _getActionText() { switch (controller.status.value) { case LivenessStatus.detectingFace: - return 'Position your face within the circle'; + return 'Position your face within the frame'; case LivenessStatus.checkLeftRotation: return 'Turn your head to the left'; case LivenessStatus.checkRightRotation: @@ -267,11 +233,34 @@ class CameraPreviewWidget extends StatelessWidget { return 'Keep your eyes open'; case LivenessStatus.readyForPhoto: return 'Perfect! Hold still'; + case LivenessStatus.countdown: + return 'Please hold your pose'; default: return 'Follow instructions'; } } + // Get appropriate icon for instructions + IconData _getInstructionIcon() { + switch (controller.status.value) { + case LivenessStatus.detectingFace: + return Icons.face; + case LivenessStatus.checkLeftRotation: + return Icons.rotate_left; + case LivenessStatus.checkRightRotation: + return Icons.rotate_right; + case LivenessStatus.checkSmile: + return Icons.sentiment_satisfied_alt; + case LivenessStatus.checkEyesOpen: + return Icons.remove_red_eye; + case LivenessStatus.readyForPhoto: + case LivenessStatus.countdown: + return Icons.photo_camera; + default: + return Icons.info; + } + } + // Function to color the face guide based on detection state Color _getFaceGuideColor() { if (controller.status.value == LivenessStatus.countdown) { @@ -281,7 +270,60 @@ class CameraPreviewWidget extends StatelessWidget { ? Colors.green : TColors.primary; } else { - return Colors.white.withOpacity(0.7); + return Colors.white; } } } + +// Custom painter for corner brackets +class CornerBracketPainter extends CustomPainter { + final Color color; + final double strokeWidth; + final BracketPosition position; + + CornerBracketPainter({ + required this.color, + required this.strokeWidth, + required this.position, + }); + + @override + void paint(Canvas canvas, Size size) { + final paint = + Paint() + ..color = color + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.square + ..style = PaintingStyle.stroke; + + final path = Path(); + + switch (position) { + case BracketPosition.topLeft: + path.moveTo(0, size.height * 0.4); + path.lineTo(0, 0); + path.lineTo(size.width * 0.4, 0); + break; + case BracketPosition.topRight: + path.moveTo(size.width * 0.6, 0); + path.lineTo(size.width, 0); + path.lineTo(size.width, size.height * 0.4); + break; + case BracketPosition.bottomLeft: + path.moveTo(0, size.height * 0.6); + path.lineTo(0, size.height); + path.lineTo(size.width * 0.4, size.height); + break; + case BracketPosition.bottomRight: + path.moveTo(size.width * 0.6, size.height); + path.lineTo(size.width, size.height); + path.lineTo(size.width, size.height * 0.6); + break; + } + + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/countdown_overlay_widget.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/countdown_overlay_widget.dart index 35287d6..3246e53 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/countdown_overlay_widget.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/countdown_overlay_widget.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:get/get.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/selfie-verification/face_liveness_detection_controller.dart'; -import 'package:sigap/src/utils/constants/colors.dart'; class CountdownOverlayWidget extends StatelessWidget { final FaceLivenessController controller; @@ -9,45 +9,55 @@ class CountdownOverlayWidget extends StatelessWidget { @override Widget build(BuildContext context) { - // Directly access the observable values without additional wrapping - final isCountdown = controller.status.value == LivenessStatus.countdown; - final countdownValue = controller.countdownSeconds.value; + return Obx(() { + final isCountdown = controller.status.value == LivenessStatus.countdown; - if (!isCountdown) { - return const SizedBox.shrink(); - } + if (!isCountdown) { + return const SizedBox.shrink(); + } + return _buildMinimalistCountdown(context); + }); + } + + Widget _buildMinimalistCountdown(BuildContext context) { return Positioned.fill( - child: Container( - color: Colors.black.withOpacity(0.3), - child: Center( - child: AnimatedScale( - scale: isCountdown ? 1.0 : 0.0, + child: Center( + child: Obx(() { + final countdownValue = controller.countdownSeconds.value; + + return AnimatedSwitcher( duration: const Duration(milliseconds: 300), - child: Container( - width: 120, - height: 120, - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.7), - shape: BoxShape.circle, - border: Border.all( - color: TColors.primary.withOpacity(0.5), - width: 3, - ), - ), - child: Center( - child: Text( - '$countdownValue', - style: const TextStyle( - color: Colors.white, - fontSize: 60, - fontWeight: FontWeight.bold, + transitionBuilder: (Widget child, Animation animation) { + return ScaleTransition( + scale: Tween(begin: 0.8, end: 1.0).animate( + CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, ), ), + child: FadeTransition(opacity: animation, child: child), + ); + }, + child: Text( + '$countdownValue', + key: ValueKey(countdownValue), + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w300, + fontSize: 120, + height: 1.0, + shadows: [ + Shadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), ), - ), - ), + ); + }), ), ); } diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/verification_progress_widget.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/verification_progress_widget.dart index 496e5b7..2e63d9c 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/verification_progress_widget.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/signup/step/selfie-verification/widgets/verification_progress_widget.dart @@ -15,88 +15,105 @@ class VerificationProgressWidget extends StatelessWidget { final completedSteps = controller.successfulSteps; return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Progress indicator - LinearProgressIndicator( - value: steps.isEmpty ? 0 : completedSteps.length / steps.length, - backgroundColor: Colors.grey.shade200, - color: TColors.primary, - minHeight: 6, - borderRadius: BorderRadius.circular(3), - ), - const SizedBox(height: 12), - - // Completed steps - Wrap( - spacing: 8, - runSpacing: 8, - children: List.generate(steps.length, (index) { - final isCompleted = index < controller.currentStepIndex; - final isInProgress = index == controller.currentStepIndex; - - return Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), + Stack( + children: [ + // Background of the progress bar + Container( + width: double.infinity, + height: 6, decoration: BoxDecoration( - color: - isCompleted - ? Colors.green.withOpacity(0.1) - : isInProgress - ? TColors.primary.withOpacity(0.1) - : Colors.grey.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: - isCompleted - ? Colors.green.withOpacity(0.3) - : isInProgress - ? TColors.primary.withOpacity(0.3) - : Colors.grey.withOpacity(0.3), + color: Colors.white.withOpacity(0.3), + borderRadius: BorderRadius.circular(3), + ), + ), + + // Filled part of the progress bar + FractionallySizedBox( + widthFactor: + steps.isEmpty ? 0 : completedSteps.length / steps.length, + child: Container( + height: 6, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [TColors.primary, Colors.greenAccent], + ), + borderRadius: BorderRadius.circular(3), + boxShadow: [ + BoxShadow( + color: TColors.primary.withOpacity(0.5), + blurRadius: 6, + spreadRadius: 0, + ), + ], ), ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - isCompleted - ? Icons.check_circle - : isInProgress - ? Icons.timelapse - : Icons.circle_outlined, - size: 16, - color: - isCompleted - ? Colors.green - : isInProgress - ? TColors.primary - : Colors.grey, + ), + ], + ), + + const SizedBox(height: 12), + + // Completed steps as a row of chips + SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: BouncingScrollPhysics(), + child: Row( + children: List.generate(steps.length, (index) { + final isCompleted = index < controller.currentStepIndex; + final isInProgress = index == controller.currentStepIndex; + + return Padding( + padding: const EdgeInsets.only(right: 8), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, ), - const SizedBox(width: 6), - Text( - _getShortStepName(steps[index]), - style: TextStyle( - fontSize: 12, - color: - isCompleted - ? Colors.green - : isInProgress - ? TColors.primary - : Colors.grey, - fontWeight: - isInProgress - ? FontWeight.bold - : FontWeight.normal, + decoration: BoxDecoration( + color: _getStepBackgroundColor( + isCompleted, + isInProgress, + ), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: _getStepBorderColor(isCompleted, isInProgress), + width: 1.5, ), ), - ], - ), - ); - }), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getStepIcon(isCompleted, isInProgress, index), + size: 16, + color: _getStepIconColor(isCompleted, isInProgress), + ), + const SizedBox(width: 6), + Text( + _getShortStepName(steps[index]), + style: TextStyle( + fontSize: 13, + fontWeight: + isInProgress + ? FontWeight.bold + : FontWeight.normal, + color: _getStepTextColor( + isCompleted, + isInProgress, + ), + ), + ), + ], + ), + ), + ); + }), + ), ), ], ), @@ -104,6 +121,73 @@ class VerificationProgressWidget extends StatelessWidget { }); } + // Get step background color based on state + Color _getStepBackgroundColor(bool isCompleted, bool isInProgress) { + if (isCompleted) { + return Colors.green.withOpacity(0.2); + } else if (isInProgress) { + return TColors.primary.withOpacity(0.2); + } else { + return Colors.white.withOpacity(0.1); + } + } + + // Get step border color based on state + Color _getStepBorderColor(bool isCompleted, bool isInProgress) { + if (isCompleted) { + return Colors.green.withOpacity(0.6); + } else if (isInProgress) { + return TColors.primary.withOpacity(0.6); + } else { + return Colors.white.withOpacity(0.3); + } + } + + // Get step icon based on state and index + IconData _getStepIcon(bool isCompleted, bool isInProgress, int index) { + if (isCompleted) { + return Icons.check_circle; + } else if (isInProgress) { + return Icons.timelapse; + } + + // Different icons for different steps + switch (index) { + case 0: + return Icons.rotate_left; + case 1: + return Icons.rotate_right; + case 2: + return Icons.sentiment_satisfied_alt; + case 3: + return Icons.remove_red_eye; + default: + return Icons.circle_outlined; + } + } + + // Get step icon color + Color _getStepIconColor(bool isCompleted, bool isInProgress) { + if (isCompleted) { + return Colors.green; + } else if (isInProgress) { + return TColors.primary; + } else { + return Colors.white.withOpacity(0.7); + } + } + + // Get step text color + Color _getStepTextColor(bool isCompleted, bool isInProgress) { + if (isCompleted) { + return Colors.green; + } else if (isInProgress) { + return TColors.primary; + } else { + return Colors.white.withOpacity(0.7); + } + } + // Get shorter step names for chips String _getShortStepName(String step) { if (step.contains('left')) return 'Look Left'; diff --git a/sigap-mobile/lib/src/features/daily-ops/data/models/models/officers_model.dart b/sigap-mobile/lib/src/features/daily-ops/data/models/models/officers_model.dart index 4d93cbf..090cc68 100644 --- a/sigap-mobile/lib/src/features/daily-ops/data/models/models/officers_model.dart +++ b/sigap-mobile/lib/src/features/daily-ops/data/models/models/officers_model.dart @@ -55,7 +55,7 @@ class OfficerModel { phone: json['phone'] as String?, email: json['email'] as String?, avatar: json['avatar'] as String?, - placeOfBirth: json['place_of_birth'] as String?, + placeOfBirth: json['birth_place'] as String?, dateOfBirth: json['date_of_birth'] != null ? DateTime.parse(json['date_of_birth'] as String) @@ -94,7 +94,7 @@ class OfficerModel { 'phone': phone, 'email': email, 'avatar': avatar, - 'place_of_birth': placeOfBirth, + 'birth_place': placeOfBirth, 'date_of_birth': dateOfBirth?.toIso8601String(), 'valid_until': validUntil?.toIso8601String(), 'qr_code': qrCode, @@ -165,7 +165,7 @@ class OfficerModel { phone: officerData['phone'], email: metadata['email'], avatar: officerData['avatar'], - placeOfBirth: officerData['place_of_birth'], + placeOfBirth: officerData['birth_place'], dateOfBirth: officerData['date_of_birth'] != null ? DateTime.parse(officerData['date_of_birth']) diff --git a/sigap-mobile/lib/src/features/personalization/data/models/models/profile_model.dart b/sigap-mobile/lib/src/features/personalization/data/models/models/profile_model.dart index e8b701f..f0e17cb 100644 --- a/sigap-mobile/lib/src/features/personalization/data/models/models/profile_model.dart +++ b/sigap-mobile/lib/src/features/personalization/data/models/models/profile_model.dart @@ -1,7 +1,7 @@ class ProfileModel { final String id; final String userId; - final String nik; + final String? nik; final String? avatar; final String? username; final String? firstName; @@ -31,7 +31,7 @@ class ProfileModel { return ProfileModel( id: json['id'] as String, userId: json['user_id'] as String, - nik: json['nik'] as String, + nik: json['nik'] as String?, avatar: json['avatar'] as String?, username: json['username'] as String?, firstName: json['first_name'] as String?, @@ -41,7 +41,7 @@ class ProfileModel { json['address'] != null ? Map.from(json['address'] as Map) : null, - placeOfBirth: json['place_of_birth'] as String?, + placeOfBirth: json['birth_place'] as String?, birthDate: json['birth_date'] != null ? DateTime.parse(json['birth_date'] as String) @@ -61,7 +61,7 @@ class ProfileModel { 'last_name': lastName, 'bio': bio, 'address': address, - 'place_of_birth': placeOfBirth, + 'birth_place': placeOfBirth, 'birth_date': birthDate?.toIso8601String(), }; } diff --git a/sigap-mobile/lib/src/features/personalization/data/models/models/user_metadata_model.dart b/sigap-mobile/lib/src/features/personalization/data/models/models/user_metadata_model.dart index 8f59813..a7d8f89 100644 --- a/sigap-mobile/lib/src/features/personalization/data/models/models/user_metadata_model.dart +++ b/sigap-mobile/lib/src/features/personalization/data/models/models/user_metadata_model.dart @@ -142,7 +142,7 @@ class UserMetadataModel { /// Convert to specialized JSON for profile completion Map toProfileCompletionJson() { return { - 'profile_status': 'complete', + 'profile_status': 'completed', 'is_officer': isOfficer, if (name != null) 'name': name, if (phone != null) 'phone': phone, diff --git a/sigap-mobile/lib/src/features/personalization/data/models/models/users_model.dart b/sigap-mobile/lib/src/features/personalization/data/models/models/users_model.dart index 0c13398..f4f9147 100644 --- a/sigap-mobile/lib/src/features/personalization/data/models/models/users_model.dart +++ b/sigap-mobile/lib/src/features/personalization/data/models/models/users_model.dart @@ -109,8 +109,8 @@ class UserModel { 'updated_at': updatedAt.toIso8601String(), 'banned_until': bannedUntil?.toIso8601String(), 'is_anonymous': isAnonymous, - if (profile != null) 'profiles': profile!.toJson(), - if (role != null) 'role': role!.toJson(), + // if (profile != null) 'profiles': profile!.toJson(), + // if (role != null) 'role': role!.toJson(), }; } diff --git a/sigap-mobile/lib/src/features/personalization/data/repositories/profile_repository.dart b/sigap-mobile/lib/src/features/personalization/data/repositories/profile_repository.dart index c2c9b23..85528cc 100644 --- a/sigap-mobile/lib/src/features/personalization/data/repositories/profile_repository.dart +++ b/sigap-mobile/lib/src/features/personalization/data/repositories/profile_repository.dart @@ -56,9 +56,7 @@ class ProfileRepository extends GetxController { final fileName = '${currentUserId}_${DateTime.now().millisecondsSinceEpoch}.jpg'; - await _supabase.storage - .from('avatars') - .upload(fileName, File(filePath)); + await _supabase.storage.from('avatars').upload(fileName, File(filePath)); final avatarUrl = _supabase.storage .from('avatars') @@ -84,42 +82,23 @@ class ProfileRepository extends GetxController { } // Update profile - Future updateProfile({ - String? firstName, - String? lastName, - String? bio, - Map? address, - DateTime? birthDate, - String? avatar, - String? username, - }) async { + Future updateProfile(ProfileModel profile) async { try { if (currentUserId == null) { throw 'User not authenticated'; } - // Build update data object - final Map updateData = {}; + final profileData = profile.toJson(); - if (firstName != null) updateData['first_name'] = firstName; - if (lastName != null) updateData['last_name'] = lastName; - if (bio != null) updateData['bio'] = bio; - if (address != null) updateData['address'] = address; - if (birthDate != null) - updateData['birth_date'] = birthDate.toIso8601String(); - if (avatar != null) updateData['avatar'] = avatar; - if (username != null) updateData['username'] = username; + final updatedProfile = + await _supabase + .from('profiles') + .update(profileData) + .eq('user_id', currentUserId!) + .select() + .single(); - // Only update if there's data to update - if (updateData.isNotEmpty) { - await _supabase - .from('profiles') - .update(updateData) - .eq('user_id', currentUserId!); - } - - // Fetch and return updated profile - return await getProfileData(); + return ProfileModel.fromJson(updatedProfile); } on PostgrestException catch (error) { _logger.e('PostgrestException in updateProfile: ${error.message}'); throw TExceptions.fromCode(error.code ?? 'unknown-error'); @@ -194,7 +173,7 @@ class ProfileRepository extends GetxController { throw 'Failed to fetch profile data: ${e.toString()}'; } } - + // Get profile by NIK Future getProfileByNIK(String nik) async { try { diff --git a/sigap-mobile/lib/src/features/personalization/data/repositories/users_repository.dart b/sigap-mobile/lib/src/features/personalization/data/repositories/users_repository.dart index f1435fb..ddd4961 100644 --- a/sigap-mobile/lib/src/features/personalization/data/repositories/users_repository.dart +++ b/sigap-mobile/lib/src/features/personalization/data/repositories/users_repository.dart @@ -13,12 +13,12 @@ class UserRepository extends GetxController { static UserRepository get instance => Get.find(); final _supabase = SupabaseService.instance.client; - final _logger = Get.find(); + final _logger = Get.put(Logger()); // Get current user ID String? get currentUserId => SupabaseService.instance.currentUserId; - // Check if user is authenticated + // Check if user is aPuthenticated bool get isAuthenticated => currentUserId != null; // Check if user is an officer @@ -42,7 +42,7 @@ class UserRepository extends GetxController { .select('role:roles(name)') .eq('id', currentUserId!) .single(); - + final roleName = (userData['role'] as Map)['name'] as String; return roleName.toLowerCase() == 'officer'; @@ -83,6 +83,32 @@ class UserRepository extends GetxController { } } + Future updateUser(UserModel user) async { + try { + if (!isAuthenticated) { + throw 'User not authenticated'; + } + + final userData = user.toJson(); + + // Update user data in database + await _supabase.from('users').update(userData).eq('id', user.id); + + // Return updated user model + return UserModel.fromJson(userData); + } on PostgrestException catch (error) { + _logger.e('PostgrestException in updateUser: ${error.message}'); + throw TExceptions.fromCode(error.code ?? 'unknown-error'); + } on FormatException catch (_) { + throw const TFormatException(); + } on PlatformException catch (e) { + throw TPlatformException(e.code).message; + } catch (e) { + _logger.e('Exception in updateUser: $e'); + throw 'Failed to update user data: ${e.toString()}'; + } + } + // Update user metadata Future updateUserMetadata(Map metadata) async { try { @@ -91,7 +117,6 @@ class UserRepository extends GetxController { } await _supabase.auth.updateUser(UserAttributes(data: metadata)); - } on AuthException catch (e) { _logger.e('AuthException in updateUserMetadata: ${e.message}'); throw TExceptions(e.message); @@ -141,7 +166,6 @@ class UserRepository extends GetxController { .from('users') .update({'phone': newPhone}) .eq('id', currentUserId!); - } on AuthException catch (e) { _logger.e('AuthException in updateUserPhone: ${e.message}'); throw TExceptions(e.message); @@ -162,7 +186,6 @@ class UserRepository extends GetxController { } await _supabase.auth.updateUser(UserAttributes(password: newPassword)); - } on AuthException catch (e) { _logger.e('AuthException in updateUserPassword: ${e.message}'); throw TExceptions(e.message); @@ -263,6 +286,40 @@ class UserRepository extends GetxController { } } + // Get user by nik + Future getUserByNik(String nik) async { + try { + final userData = + await _supabase + .from('users') + .select('*, profiles(*), role:roles(*)') + .eq('profiles.nik', nik) + .maybeSingle(); + + if (userData == null) return null; + + return UserModel.fromJson(userData); + } catch (e) { + _logger.e('Exception in getUserByNik: $e'); + return null; + } + } + + Future isNikExists(String nik) async { + try { + final result = await _supabase + .from('profiles') + .select('id') + .eq('nik', nik) + .limit(1); + + return result.isNotEmpty; + } catch (e) { + _logger.e('Exception in isNikExists: $e'); + return false; // Assume NIK doesn't exist if error + } + } + // Chekch if email is already in use Future isEmailInUse(String email) async { try { @@ -283,7 +340,6 @@ class UserRepository extends GetxController { } } - // Search users by name/username/email Future> searchUsers(String query, {int limit = 20}) async { try { diff --git a/sigap-mobile/lib/src/shared/widgets/state_screeen/state_screen.dart b/sigap-mobile/lib/src/shared/widgets/state_screeen/state_screen.dart index 546c144..30eb0cf 100644 --- a/sigap-mobile/lib/src/shared/widgets/state_screeen/state_screen.dart +++ b/sigap-mobile/lib/src/shared/widgets/state_screeen/state_screen.dart @@ -37,75 +37,82 @@ class StateScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - body: SingleChildScrollView( - child: Padding( - padding: DSpacingStyle.paddingWithAppBarHeight * 2, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Image, Icon, or Lottie - if (icon != null) - Icon( - icon, - size: THelperFunctions.screenWidth() * 0.3, - color: TColors.primary, - ) - else if (isLottie == true && image != null) - Lottie.asset( - image!, - width: THelperFunctions.screenWidth() * 1.5, - ) - else if (image != null) - Image( - image: AssetImage(image!), - width: THelperFunctions.screenWidth(), - ), - const SizedBox(height: TSizes.spaceBtwSections), - - // Title & subtitle - Text( - title, - style: Theme.of(context).textTheme.headlineMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: TSizes.spaceBtwItems), - Text( - subtitle, - style: Theme.of(context).textTheme.labelMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: TSizes.spaceBtwSections), - - // Button - if (showButton) - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: onPressed, - child: Text(primaryButtonTitle), + body: Center( + child: SingleChildScrollView( + child: Padding( + padding: DSpacingStyle.paddingWithAppBarHeight, + child: Container( + width: double.infinity, + constraints: BoxConstraints( + maxWidth: THelperFunctions.screenWidth() * 0.9, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Image, Icon, or Lottie + if (icon != null) + Icon( + icon, + size: THelperFunctions.screenWidth() * 0.3, + color: TColors.primary, + ) + else if (isLottie == true && image != null) + Lottie.asset( + image!, + width: THelperFunctions.screenWidth() * 0.8, + ) + else if (image != null) + Image( + image: AssetImage(image!), + width: THelperFunctions.screenWidth() * 0.8, ), - ), - const SizedBox(height: TSizes.spaceBtwItems), + const SizedBox(height: TSizes.spaceBtwSections), - if (secondaryButton) - SizedBox( - width: double.infinity, - child: OutlinedButton( - onPressed: onSecondaryPressed, - style: ElevatedButton.styleFrom( - side: const BorderSide(color: Colors.transparent), - ), - child: Text( - secondaryTitle, - style: const TextStyle(color: TColors.primary), + // Title & subtitle + Text( + title, + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: TSizes.spaceBtwItems), + Text( + subtitle, + style: Theme.of(context).textTheme.labelMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: TSizes.spaceBtwSections), + + // Button + if (showButton) + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: onPressed, + child: Text(primaryButtonTitle), ), ), - ), + const SizedBox(height: TSizes.spaceBtwItems), - // Additional child widget - if (child != null) child!, - ], + if (secondaryButton) + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: onSecondaryPressed, + style: ElevatedButton.styleFrom( + side: const BorderSide(color: Colors.transparent), + ), + child: Text( + secondaryTitle, + style: const TextStyle(color: TColors.primary), + ), + ), + ), + + // Additional child widget + if (child != null) child!, + ], + ), ), ), ),